feat(editor): migrate typst mermaid to native (#14499)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Native/WASM Mermaid and Typst SVG preview rendering on desktop and
mobile, plus cross-platform Preview plugin integrations.

* **Improvements**
* Centralized, sanitized rendering bridge with automatic Typst
font-directory handling and configurable native renderer selection.
* More consistent and robust error serialization and worker-backed
preview flows for improved stability and performance.

* **Tests**
* Extensive unit and integration tests for preview rendering, font
discovery, sanitization, and error serialization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-20 04:04:40 +08:00
committed by GitHub
parent 16a8f17717
commit 7ac8b14b65
85 changed files with 4926 additions and 835 deletions
Generated
+2274 -429
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -36,7 +36,7 @@ resolver = "3"
criterion2 = { version = "3", default-features = false }
crossbeam-channel = "0.5"
dispatch2 = "0.3"
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
homedir = "0.3"
@@ -59,6 +59,7 @@ resolver = "3"
lru = "0.16"
matroska = "0.30"
memory-indexer = "0.3.0"
mermaid-rs-renderer = { git = "https://github.com/toeverything/mermaid-rs-renderer", rev = "fba9097", default-features = false }
mimalloc = "0.1"
mp4parse = "0.17"
nanoid = "0.4"
@@ -122,6 +123,14 @@ resolver = "3"
tree-sitter-rust = { version = "0.24" }
tree-sitter-scala = { version = "0.24" }
tree-sitter-typescript = { version = "0.23" }
typst = "0.14.2"
typst-as-lib = { version = "0.15.4", default-features = false, features = [
"packages",
"typst-kit-embed-fonts",
"typst-kit-fonts",
"ureq",
] }
typst-svg = "0.14.2"
uniffi = "0.29"
url = { version = "2.5" }
uuid = "1.8"
@@ -32,6 +32,7 @@
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"playwright": "=1.58.2",
"vitest": "^4.0.18"
},
"exports": {
@@ -35,6 +35,7 @@
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"playwright": "=1.58.2",
"vitest": "^4.0.18"
},
"exports": {
+1
View File
@@ -34,6 +34,7 @@
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"playwright": "=1.58.2",
"vitest": "^4.0.18"
},
"exports": {
+1
View File
@@ -42,6 +42,7 @@
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser-playwright": "^4.0.18",
"playwright": "=1.58.2",
"vite": "^7.2.7",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-wasm": "^3.5.0",
+48
View File
@@ -0,0 +1,48 @@
[graph]
all-features = true
exclude-dev = true
targets = [
"x86_64-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-linux-android",
"aarch64-apple-ios",
"aarch64-apple-ios-sim",
]
[licenses]
allow = [
"0BSD",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CC0-1.0",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"MPL-2.0",
"Unicode-3.0",
"Unlicense",
"Zlib",
]
confidence-threshold = 0.93
unused-allowed-license = "allow"
version = 2
[[licenses.exceptions]]
allow = ["AGPL-3.0-only"]
crate = "llm_adapter"
[[licenses.exceptions]]
allow = ["AGPL-3.0-or-later"]
crate = "memory-indexer"
[[licenses.exceptions]]
allow = ["AGPL-3.0-or-later"]
crate = "path-ext"
[licenses.private]
ignore = true
+1
View File
@@ -2,6 +2,7 @@
edition = "2024"
license-file = "LICENSE"
name = "affine_server_native"
publish = false
version = "1.0.0"
[lib]
@@ -10,6 +10,7 @@ interface TestOps extends OpSchema {
add: [{ a: number; b: number }, number];
bin: [Uint8Array, Uint8Array];
sub: [Uint8Array, number];
init: [{ fastText?: boolean } | undefined, { ok: true }];
}
declare module 'vitest' {
@@ -84,6 +85,55 @@ describe('op client', () => {
expect(data.byteLength).toBe(0);
});
it('should send optional payload call with abort signal', async ctx => {
const abortController = new AbortController();
const result = ctx.producer.call(
'init',
{ fastText: true },
abortController.signal
);
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "init:1",
"name": "init",
"payload": {
"fastText": true,
},
"type": "call",
}
`);
ctx.handlers.return({
type: 'return',
id: 'init:1',
data: { ok: true },
});
await expect(result).resolves.toEqual({ ok: true });
});
it('should send undefined payload for optional input call', async ctx => {
const result = ctx.producer.call('init', undefined);
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "init:1",
"name": "init",
"payload": undefined,
"type": "call",
}
`);
ctx.handlers.return({
type: 'return',
id: 'init:1',
data: { ok: true },
});
await expect(result).resolves.toEqual({ ok: true });
});
it('should cancel call', async ctx => {
const promise = ctx.producer.call('add', { a: 1, b: 2 });
@@ -40,18 +40,14 @@ describe('op consumer', () => {
it('should throw if no handler registered', async ctx => {
ctx.handlers.call({ type: 'call', id: 'add:1', name: 'add', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"error": {
"message": "Handler for operation [add] is not registered.",
"name": "Error",
},
"id": "add:1",
"type": "return",
},
]
`);
expect(ctx.postMessage.mock.lastCall?.[0]).toMatchObject({
type: 'return',
id: 'add:1',
error: {
message: 'Handler for operation [add] is not registered.',
name: 'Error',
},
});
});
it('should handle call message', async ctx => {
@@ -73,6 +69,38 @@ describe('op consumer', () => {
`);
});
it('should serialize string errors with message', async ctx => {
ctx.consumer.register('any', () => {
throw 'worker panic';
});
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls[0][0]).toMatchObject({
type: 'return',
id: 'any:1',
error: {
name: 'Error',
message: 'worker panic',
},
});
});
it('should serialize plain object errors with fallback message', async ctx => {
ctx.consumer.register('any', () => {
throw { reason: 'panic', code: 'E_PANIC' };
});
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
await vi.advanceTimersToNextTimerAsync();
const message = ctx.postMessage.mock.calls[0][0]?.error?.message;
expect(typeof message).toBe('string');
expect(message).toContain('"reason":"panic"');
expect(message).toContain('"code":"E_PANIC"');
});
it('should handle cancel message', async ctx => {
ctx.consumer.register('add', ({ a, b }, { signal }) => {
const { reject, resolve, promise } = Promise.withResolvers<number>();
+92 -18
View File
@@ -16,6 +16,96 @@ import {
} from './message';
import type { OpInput, OpNames, OpOutput, OpSchema } from './types';
const SERIALIZABLE_ERROR_FIELDS = [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
] as const;
type SerializableErrorShape = Partial<
Record<(typeof SERIALIZABLE_ERROR_FIELDS)[number], unknown>
> & {
name?: string;
message?: string;
};
function getFallbackErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error && error.message) {
return error.message;
}
if (
typeof error === 'number' ||
typeof error === 'boolean' ||
typeof error === 'bigint' ||
typeof error === 'symbol'
) {
return String(error);
}
if (error === null || error === undefined) {
return 'Unknown error';
}
try {
const jsonMessage = JSON.stringify(error);
if (jsonMessage && jsonMessage !== '{}') {
return jsonMessage;
}
} catch {
return 'Unknown error';
}
return 'Unknown error';
}
function serializeError(error: unknown): Error {
const valueToPick =
error && typeof error === 'object'
? error
: ({} as Record<string, unknown>);
const serialized = pick(
valueToPick,
SERIALIZABLE_ERROR_FIELDS
) as SerializableErrorShape;
if (!serialized.message || typeof serialized.message !== 'string') {
serialized.message = getFallbackErrorMessage(error);
}
if (!serialized.name || typeof serialized.name !== 'string') {
if (error instanceof Error && error.name) {
serialized.name = error.name;
} else if (error && typeof error === 'object') {
const constructorName = error.constructor?.name;
serialized.name =
typeof constructorName === 'string' && constructorName.length > 0
? constructorName
: 'Error';
} else {
serialized.name = 'Error';
}
}
if (
!serialized.stacktrace &&
error instanceof Error &&
typeof error.stack === 'string'
) {
serialized.stacktrace = error.stack;
}
return serialized as Error;
}
interface OpCallContext {
signal: AbortSignal;
}
@@ -71,15 +161,7 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
this.port.postMessage({
type: 'return',
id: msg.id,
error: pick(error, [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
]),
error: serializeError(error),
} satisfies ReturnMessage);
},
complete: () => {
@@ -109,15 +191,7 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
this.port.postMessage({
type: 'error',
id: msg.id,
error: pick(error, [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
]),
error: serializeError(error),
} satisfies SubscriptionErrorMessage);
},
complete: () => {
+10 -1
View File
@@ -12,7 +12,16 @@ export interface OpSchema {
[key: string]: [any, any?];
}
type RequiredInput<In> = In extends void ? [] : In extends never ? [] : [In];
type IsAny<T> = 0 extends 1 & T ? true : false;
type RequiredInput<In> =
IsAny<In> extends true
? [In]
: [In] extends [never]
? []
: [In] extends [void]
? []
: [In];
export type OpNames<T extends OpSchema> = ValuesOf<KeyToKey<T>>;
export type OpInput<
+1
View File
@@ -2,6 +2,7 @@
edition = "2024"
license-file = "LICENSE"
name = "affine_common"
publish = false
version = "0.1.0"
[features]
@@ -19,6 +19,7 @@ import app.affine.pro.plugin.AFFiNEThemePlugin
import app.affine.pro.plugin.AuthPlugin
import app.affine.pro.plugin.HashCashPlugin
import app.affine.pro.plugin.NbStorePlugin
import app.affine.pro.plugin.PreviewPlugin
import app.affine.pro.service.GraphQLService
import app.affine.pro.service.SSEService
import app.affine.pro.service.WebService
@@ -52,6 +53,7 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
AuthPlugin::class.java,
HashCashPlugin::class.java,
NbStorePlugin::class.java,
PreviewPlugin::class.java,
)
)
}
@@ -1,8 +1,6 @@
package app.affine.pro.ai.chat
import com.affine.pro.graphql.GetCopilotHistoriesQuery
import com.affine.pro.graphql.fragment.CopilotChatHistory
import com.affine.pro.graphql.fragment.CopilotChatMessage
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@@ -53,7 +51,7 @@ data class ChatMessage(
createAt = Clock.System.now(),
)
fun from(message: CopilotChatMessage) = ChatMessage(
fun from(message: CopilotChatHistory.Message) = ChatMessage(
id = message.id,
role = Role.fromValue(message.role),
content = message.content,
@@ -0,0 +1,106 @@
package app.affine.pro.plugin
import android.net.Uri
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import timber.log.Timber
import uniffi.affine_mobile_native.renderMermaidPreviewSvg
import uniffi.affine_mobile_native.renderTypstPreviewSvg
import java.io.File
private fun JSObject.getOptionalString(key: String): String? {
return if (has(key) && !isNull(key)) getString(key) else null
}
private fun JSObject.getOptionalDouble(key: String): Double? {
return if (has(key) && !isNull(key)) getDouble(key) else null
}
private fun resolveLocalFontDir(fontUrl: String): String? {
val uri = Uri.parse(fontUrl)
val path = when {
uri.scheme == null -> {
val file = File(fontUrl)
if (!file.isAbsolute) {
return null
}
file.path
}
uri.scheme == "file" -> uri.path
else -> null
} ?: return null
val file = File(path)
val directory = if (file.isDirectory) file else file.parentFile ?: return null
return directory.absolutePath
}
private fun JSObject.resolveTypstFontDirs(): List<String>? {
if (!has("fontUrls") || isNull("fontUrls")) {
return null
}
val fontUrls = optJSONArray("fontUrls")
?: throw IllegalArgumentException("Typst preview fontUrls must be an array of strings.")
val fontDirs = buildList(fontUrls.length()) {
repeat(fontUrls.length()) { index ->
val fontUrl = fontUrls.optString(index, null)
?: throw IllegalArgumentException("Typst preview fontUrls must be strings.")
val fontDir = resolveLocalFontDir(fontUrl)
?: throw IllegalArgumentException("Typst preview on mobile only supports local font file URLs or absolute font directories.")
add(fontDir)
}
}
return fontDirs.distinct()
}
@CapacitorPlugin(name = "Preview")
class PreviewPlugin : Plugin() {
@PluginMethod
fun renderMermaidSvg(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val code = call.getStringEnsure("code")
val options = call.getObject("options")
val svg = renderMermaidPreviewSvg(
code = code,
theme = options?.getOptionalString("theme"),
fontFamily = options?.getOptionalString("fontFamily"),
fontSize = options?.getOptionalDouble("fontSize"),
)
call.resolve(JSObject().apply {
put("svg", svg)
})
} catch (e: Exception) {
Timber.e(e, "Failed to render Mermaid preview.")
call.reject("Failed to render Mermaid preview.", null, e)
}
}
}
@PluginMethod
fun renderTypstSvg(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val code = call.getStringEnsure("code")
val options = call.getObject("options")
val svg = renderTypstPreviewSvg(
code = code,
fontDirs = options?.resolveTypstFontDirs(),
cacheDir = context.cacheDir.absolutePath,
)
call.resolve(JSObject().apply {
put("svg", svg)
})
} catch (e: Exception) {
Timber.e(e, "Failed to render Typst preview.")
call.reject("Failed to render Typst preview.", null, e)
}
}
}
}
@@ -72,7 +72,7 @@ class GraphQLService @Inject constructor() {
).mapCatching { data ->
data.currentUser?.copilot?.chats?.paginatedCopilotChats?.edges?.map { item -> item.node.copilotChatHistory }?.firstOrNull { history ->
history.sessionId == sessionId
}?.messages?.map { msg -> msg.copilotChatMessage } ?: emptyList()
}?.messages ?: emptyList()
}
suspend fun getCopilotHistoryIds(
@@ -792,6 +792,10 @@ internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback {
@@ -816,6 +820,10 @@ internal interface IntegrityCheckingUniffiLib : Library {
): Short
fun uniffi_affine_mobile_native_checksum_func_new_doc_storage_pool(
): Short
fun uniffi_affine_mobile_native_checksum_func_render_mermaid_preview_svg(
): Short
fun uniffi_affine_mobile_native_checksum_func_render_typst_preview_svg(
): Short
fun uniffi_affine_mobile_native_checksum_method_docstoragepool_clear_clocks(
): Short
fun uniffi_affine_mobile_native_checksum_method_docstoragepool_connect(
@@ -1017,6 +1025,10 @@ fun uniffi_affine_mobile_native_fn_func_hashcash_mint(`resource`: RustBuffer.ByV
): RustBuffer.ByValue
fun uniffi_affine_mobile_native_fn_func_new_doc_storage_pool(uniffi_out_err: UniffiRustCallStatus,
): Pointer
fun uniffi_affine_mobile_native_fn_func_render_mermaid_preview_svg(`code`: RustBuffer.ByValue,`theme`: RustBuffer.ByValue,`fontFamily`: RustBuffer.ByValue,`fontSize`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
fun uniffi_affine_mobile_native_fn_func_render_typst_preview_svg(`code`: RustBuffer.ByValue,`fontDirs`: RustBuffer.ByValue,`cacheDir`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
fun ffi_affine_mobile_native_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
fun ffi_affine_mobile_native_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus,
@@ -1149,6 +1161,12 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) {
if (lib.uniffi_affine_mobile_native_checksum_func_new_doc_storage_pool() != 32882.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_affine_mobile_native_checksum_func_render_mermaid_preview_svg() != 54334.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_affine_mobile_native_checksum_func_render_typst_preview_svg() != 42796.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_affine_mobile_native_checksum_method_docstoragepool_clear_clocks() != 51151.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
@@ -3178,6 +3196,38 @@ public object FfiConverterOptionalLong: FfiConverterRustBuffer<kotlin.Long?> {
/**
* @suppress
*/
public object FfiConverterOptionalDouble: FfiConverterRustBuffer<kotlin.Double?> {
override fun read(buf: ByteBuffer): kotlin.Double? {
if (buf.get().toInt() == 0) {
return null
}
return FfiConverterDouble.read(buf)
}
override fun allocationSize(value: kotlin.Double?): ULong {
if (value == null) {
return 1UL
} else {
return 1UL + FfiConverterDouble.allocationSize(value)
}
}
override fun write(value: kotlin.Double?, buf: ByteBuffer) {
if (value == null) {
buf.put(0)
} else {
buf.put(1)
FfiConverterDouble.write(value, buf)
}
}
}
/**
* @suppress
*/
@@ -3584,4 +3634,24 @@ public object FfiConverterSequenceTypeSearchHit: FfiConverterRustBuffer<List<Sea
}
@Throws(UniffiException::class) fun `renderMermaidPreviewSvg`(`code`: kotlin.String, `theme`: kotlin.String?, `fontFamily`: kotlin.String?, `fontSize`: kotlin.Double?): kotlin.String {
return FfiConverterString.lift(
uniffiRustCallWithError(UniffiException) { _status ->
UniffiLib.INSTANCE.uniffi_affine_mobile_native_fn_func_render_mermaid_preview_svg(
FfiConverterString.lower(`code`),FfiConverterOptionalString.lower(`theme`),FfiConverterOptionalString.lower(`fontFamily`),FfiConverterOptionalDouble.lower(`fontSize`),_status)
}
)
}
@Throws(UniffiException::class) fun `renderTypstPreviewSvg`(`code`: kotlin.String, `fontDirs`: List<kotlin.String>?, `cacheDir`: kotlin.String?): kotlin.String {
return FfiConverterString.lift(
uniffiRustCallWithError(UniffiException) { _status ->
UniffiLib.INSTANCE.uniffi_affine_mobile_native_fn_func_render_typst_preview_svg(
FfiConverterString.lower(`code`),FfiConverterOptionalSequenceString.lower(`fontDirs`),FfiConverterOptionalString.lower(`cacheDir`),_status)
}
)
}
@@ -15,6 +15,7 @@ import {
ServersService,
ValidatorProvider,
} from '@affine/core/modules/cloud';
import { registerNativePreviewHandlers } from '@affine/core/modules/code-block-preview-renderer';
import { DocsService } from '@affine/core/modules/doc';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { I18nProvider } from '@affine/core/modules/i18n';
@@ -54,6 +55,7 @@ import { AIButton } from './plugins/ai-button';
import { Auth } from './plugins/auth';
import { HashCash } from './plugins/hashcash';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { Preview } from './plugins/preview';
import { writeEndpointToken } from './proxy';
const storeManagerClient = createStoreManagerClient();
@@ -85,6 +87,11 @@ framework.impl(NbstoreProvider, {
});
const frameworkProvider = framework.provider();
registerNativePreviewHandlers({
renderMermaidSvg: request => Preview.renderMermaidSvg(request),
renderTypstSvg: request => Preview.renderTypstSvg(request),
});
framework.impl(PopupWindowProvider, {
open: (url: string) => {
InAppBrowser.open({
@@ -0,0 +1,16 @@
export interface PreviewPlugin {
renderMermaidSvg(options: {
code: string;
options?: {
theme?: string;
fontFamily?: string;
fontSize?: number;
};
}): Promise<{ svg: string }>;
renderTypstSvg(options: {
code: string;
options?: {
fontUrls?: string[];
};
}): Promise<{ svg: string }>;
}
@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { PreviewPlugin } from './definitions';
const Preview = registerPlugin<PreviewPlugin>('Preview');
export * from './definitions';
export { Preview };
@@ -1,5 +1,6 @@
import { dialogHandlers } from './dialog';
import { dbEventsV1, dbHandlersV1, nbstoreHandlers } from './nbstore';
import { previewHandlers } from './preview';
import { provideExposed } from './provide';
import { workspaceEvents, workspaceHandlers } from './workspace';
@@ -8,6 +9,7 @@ export const handlers = {
nbstore: nbstoreHandlers,
workspace: workspaceHandlers,
dialog: dialogHandlers,
preview: previewHandlers,
};
export const events = {
@@ -0,0 +1,69 @@
import fs from 'node:fs';
import path from 'node:path';
import {
type MermaidRenderRequest,
type MermaidRenderResult,
renderMermaidSvg,
renderTypstSvg,
type TypstRenderRequest,
type TypstRenderResult,
} from '@affine/native';
const TYPST_FONT_DIRS_ENV = 'AFFINE_TYPST_FONT_DIRS';
function parseTypstFontDirsFromEnv() {
const value = process.env[TYPST_FONT_DIRS_ENV];
if (!value) {
return [];
}
return value
.split(path.delimiter)
.map(dir => dir.trim())
.filter(Boolean);
}
function getTypstFontDirCandidates() {
const resourcesPath = process.resourcesPath ?? '';
return [
...parseTypstFontDirsFromEnv(),
path.join(resourcesPath, 'fonts'),
path.join(resourcesPath, 'js', 'fonts'),
path.join(resourcesPath, 'app.asar.unpacked', 'fonts'),
path.join(resourcesPath, 'app.asar.unpacked', 'js', 'fonts'),
];
}
function resolveTypstFontDirs() {
return Array.from(
new Set(getTypstFontDirCandidates().map(dir => path.resolve(dir)))
).filter(dir => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory());
}
function withTypstFontDirs(
request: TypstRenderRequest,
fontDirs: string[]
): TypstRenderRequest {
const nextOptions = request.options ? { ...request.options } : {};
if (!nextOptions.fontDirs?.length) {
nextOptions.fontDirs = fontDirs;
}
return { ...request, options: nextOptions };
}
const typstFontDirs = resolveTypstFontDirs();
export const previewHandlers = {
renderMermaidSvg: async (
request: MermaidRenderRequest
): Promise<MermaidRenderResult> => {
return renderMermaidSvg(request);
},
renderTypstSvg: async (
request: TypstRenderRequest
): Promise<TypstRenderResult> => {
return renderTypstSvg(withTypstFontDirs(request, typstFontDirs));
},
};
@@ -0,0 +1,85 @@
import path from 'node:path';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
const { native } = vi.hoisted(() => ({
native: {
renderMermaidSvg: vi.fn(),
renderTypstSvg: vi.fn(),
},
}));
vi.mock('@affine/native', () => native);
const tmpDir = path.join(__dirname, 'tmp');
const typstFontDirA = path.join(tmpDir, 'fonts-a');
const typstFontDirB = path.join(tmpDir, 'fonts-b');
async function loadPreviewHandlers() {
vi.resetModules();
const module = await import('../../src/helper/preview');
return module.previewHandlers;
}
describe('helper preview handlers', () => {
beforeEach(async () => {
await fs.ensureDir(typstFontDirA);
await fs.ensureDir(typstFontDirB);
process.env.AFFINE_TYPST_FONT_DIRS = [
typstFontDirA,
typstFontDirB,
path.join(tmpDir, 'missing'),
].join(path.delimiter);
native.renderMermaidSvg.mockReset();
native.renderTypstSvg.mockReset();
native.renderMermaidSvg.mockReturnValue({
svg: '<svg><text>mermaid</text></svg>',
});
native.renderTypstSvg.mockReturnValue({
svg: '<svg><text>typst</text></svg>',
});
});
afterEach(async () => {
delete process.env.AFFINE_TYPST_FONT_DIRS;
await fs.remove(tmpDir);
});
test('passes mermaid request to native renderer', async () => {
const previewHandlers = await loadPreviewHandlers();
const request = { code: 'flowchart TD; A-->B' };
await previewHandlers.renderMermaidSvg(request);
expect(native.renderMermaidSvg).toHaveBeenCalledWith(request);
});
test('injects resolved fontDirs into typst requests', async () => {
const previewHandlers = await loadPreviewHandlers();
await previewHandlers.renderTypstSvg({ code: '= hello' });
const [request] = native.renderTypstSvg.mock.calls[0];
expect(request.options?.fontDirs).toEqual(
expect.arrayContaining([
path.resolve(typstFontDirA),
path.resolve(typstFontDirB),
])
);
});
test('keeps explicit typst fontDirs', async () => {
const previewHandlers = await loadPreviewHandlers();
const request = {
code: '= hello',
options: {
fontDirs: ['/tmp/custom-fonts'],
},
};
await previewHandlers.renderTypstSvg(request);
expect(native.renderTypstSvg).toHaveBeenCalledWith(request);
});
});
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Recouse/EventSource",
"state" : {
"revision" : "7b2f4f585d3927876bd76eaede9fdff779eff102",
"version" : "0.1.5"
"revision" : "713f8c0a0270a80a968c007ddc0d6067e80a5393",
"version" : "0.1.7"
}
},
{
@@ -41,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Litext",
"state" : {
"revision" : "c7e83f2f580ce34a102ca9ba9d2bb24e507dccd9",
"version" : "0.5.6"
"revision" : "a2ed9b63ae623a20591effc72f9db7d04e41a64c",
"version" : "1.2.1"
}
},
{
@@ -77,8 +77,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/RevenueCat/purchases-ios-spm.git",
"state" : {
"revision" : "8f5df97653eb361a2097119479332afccf0aa816",
"version" : "5.58.0"
"revision" : "2913a336eb37dc06795cdbaa5b5de330b6707669",
"version" : "5.65.0"
}
},
{
@@ -34,6 +34,7 @@ class AFFiNEViewController: CAPBridgeViewController {
NavigationGesturePlugin(),
NbStorePlugin(),
PayWallPlugin(associatedController: self),
PreviewPlugin(),
]
plugins.forEach { bridge?.registerPluginInstance($0) }
}
@@ -0,0 +1,119 @@
import Foundation
import Capacitor
private func resolveLocalFontDir(from fontURL: String) -> String? {
let path: String
if fontURL.hasPrefix("file://") {
guard let url = URL(string: fontURL), url.isFileURL else {
return nil
}
path = url.path
} else {
let candidate = (fontURL as NSString).standardizingPath
guard candidate.hasPrefix("/") else {
return nil
}
path = candidate
}
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),
isDirectory.boolValue
{
return path
}
let directory = (path as NSString).deletingLastPathComponent
return directory.isEmpty ? nil : directory
}
private func resolveTypstFontDirs(from options: [AnyHashable: Any]?) throws -> [String]? {
guard let rawFontUrls = options?["fontUrls"] else {
return nil
}
guard let fontUrls = rawFontUrls as? [Any] else {
throw NSError(
domain: "PreviewPlugin",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Typst preview fontUrls must be an array of strings."
]
)
}
var seenFontDirs = Set<String>()
var orderedFontDirs = [String]()
orderedFontDirs.reserveCapacity(fontUrls.count)
for fontUrl in fontUrls {
guard let fontURL = fontUrl as? String else {
throw NSError(
domain: "PreviewPlugin",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Typst preview fontUrls must be strings."
]
)
}
guard let fontDir = resolveLocalFontDir(from: fontURL) else {
throw NSError(
domain: "PreviewPlugin",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Typst preview on mobile only supports local font file URLs or absolute font directories."
]
)
}
if seenFontDirs.insert(fontDir).inserted {
orderedFontDirs.append(fontDir)
}
}
return orderedFontDirs
}
@objc(PreviewPlugin)
public class PreviewPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "PreviewPlugin"
public let jsName = "Preview"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "renderMermaidSvg", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "renderTypstSvg", returnType: CAPPluginReturnPromise),
]
@objc func renderMermaidSvg(_ call: CAPPluginCall) {
DispatchQueue.global(qos: .userInitiated).async {
do {
let code = try call.getStringEnsure("code")
let options = call.getObject("options")
let svg = try renderMermaidPreviewSvg(
code: code,
theme: options?["theme"] as? String,
fontFamily: options?["fontFamily"] as? String,
fontSize: (options?["fontSize"] as? NSNumber)?.doubleValue
)
call.resolve(["svg": svg])
} catch {
call.reject("Failed to render Mermaid preview, \(error)", nil, error)
}
}
}
@objc func renderTypstSvg(_ call: CAPPluginCall) {
DispatchQueue.global(qos: .userInitiated).async {
do {
let code = try call.getStringEnsure("code")
let options = call.getObject("options")
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
let fontDirs = try resolveTypstFontDirs(from: options)
let svg = try renderTypstPreviewSvg(code: code, fontDirs: fontDirs, cacheDir: cacheDir)
call.resolve(["svg": svg])
} catch {
call.reject("Failed to render Typst preview, \(error)", nil, error)
}
}
}
}
@@ -2265,6 +2265,30 @@ fileprivate struct FfiConverterOptionInt64: FfiConverterRustBuffer {
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterOptionDouble: FfiConverterRustBuffer {
typealias SwiftType = Double?
public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
guard let value = value else {
writeInt(&buf, Int8(0))
return
}
writeInt(&buf, Int8(1))
FfiConverterDouble.write(value, into: &buf)
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType {
switch try readInt(&buf) as Int8 {
case 0: return nil
case 1: return try FfiConverterDouble.read(from: &buf)
default: throw UniffiInternalError.unexpectedOptionalTag
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -2644,6 +2668,25 @@ public func newDocStoragePool() -> DocStoragePool {
)
})
}
public func renderMermaidPreviewSvg(code: String, theme: String?, fontFamily: String?, fontSize: Double?)throws -> String {
return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeUniffiError_lift) {
uniffi_affine_mobile_native_fn_func_render_mermaid_preview_svg(
FfiConverterString.lower(code),
FfiConverterOptionString.lower(theme),
FfiConverterOptionString.lower(fontFamily),
FfiConverterOptionDouble.lower(fontSize),$0
)
})
}
public func renderTypstPreviewSvg(code: String, fontDirs: [String]?, cacheDir: String?)throws -> String {
return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeUniffiError_lift) {
uniffi_affine_mobile_native_fn_func_render_typst_preview_svg(
FfiConverterString.lower(code),
FfiConverterOptionSequenceString.lower(fontDirs),
FfiConverterOptionString.lower(cacheDir),$0
)
})
}
private enum InitializationResult {
case ok
@@ -2666,6 +2709,12 @@ private let initializationResult: InitializationResult = {
if (uniffi_affine_mobile_native_checksum_func_new_doc_storage_pool() != 32882) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_affine_mobile_native_checksum_func_render_mermaid_preview_svg() != 54334) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_affine_mobile_native_checksum_func_render_typst_preview_svg() != 42796) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_affine_mobile_native_checksum_method_docstoragepool_clear_clocks() != 51151) {
return InitializationResult.apiChecksumMismatch
}
@@ -450,6 +450,16 @@ RustBuffer uniffi_affine_mobile_native_fn_func_hashcash_mint(RustBuffer resource
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_FUNC_NEW_DOC_STORAGE_POOL
void*_Nonnull uniffi_affine_mobile_native_fn_func_new_doc_storage_pool(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_FUNC_RENDER_MERMAID_PREVIEW_SVG
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_FUNC_RENDER_MERMAID_PREVIEW_SVG
RustBuffer uniffi_affine_mobile_native_fn_func_render_mermaid_preview_svg(RustBuffer code, RustBuffer theme, RustBuffer font_family, RustBuffer font_size, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_FUNC_RENDER_TYPST_PREVIEW_SVG
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_FUNC_RENDER_TYPST_PREVIEW_SVG
RustBuffer uniffi_affine_mobile_native_fn_func_render_typst_preview_svg(RustBuffer code, RustBuffer font_dirs, RustBuffer cache_dir, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_AFFINE_MOBILE_NATIVE_RUSTBUFFER_ALLOC
@@ -742,6 +752,18 @@ uint16_t uniffi_affine_mobile_native_checksum_func_hashcash_mint(void
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_FUNC_NEW_DOC_STORAGE_POOL
uint16_t uniffi_affine_mobile_native_checksum_func_new_doc_storage_pool(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_FUNC_RENDER_MERMAID_PREVIEW_SVG
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_FUNC_RENDER_MERMAID_PREVIEW_SVG
uint16_t uniffi_affine_mobile_native_checksum_func_render_mermaid_preview_svg(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_FUNC_RENDER_TYPST_PREVIEW_SVG
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_FUNC_RENDER_TYPST_PREVIEW_SVG
uint16_t uniffi_affine_mobile_native_checksum_func_render_typst_preview_svg(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_METHOD_DOCSTORAGEPOOL_CLEAR_CLOCKS
@@ -8,4 +8,18 @@
import ApolloAPI
/// The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
public typealias JSON = String
public struct JSON: CustomScalarType, Hashable, ExpressibleByDictionaryLiteral {
public let value: JSONValue
public init(_jsonValue value: JSONValue) throws {
self.value = value
}
public init(dictionaryLiteral elements: (String, JSONValue)...) {
value = ApolloAPI.JSONObject(uniqueKeysWithValues: elements) as JSONValue
}
public var _jsonValue: JSONValue {
value
}
}
@@ -8,4 +8,22 @@
import ApolloAPI
/// The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
public typealias JSONObject = String
public struct JSONObject: CustomScalarType, Hashable, ExpressibleByDictionaryLiteral {
public let object: ApolloAPI.JSONObject
public init(_jsonValue value: JSONValue) throws {
object = try ApolloAPI.JSONObject(_jsonValue: value)
}
public init(_ object: ApolloAPI.JSONObject) {
self.object = object
}
public init(dictionaryLiteral elements: (String, JSONValue)...) {
object = ApolloAPI.JSONObject(uniqueKeysWithValues: elements)
}
public var _jsonValue: JSONValue {
object
}
}
@@ -26,6 +26,7 @@ private extension InputBoxData {
}
public extension ChatManager {
@MainActor
func startUserRequest(editorData: InputBoxData, sessionId: String) {
append(sessionId: sessionId, UserMessageCellViewModel(
id: .init(),
@@ -163,7 +164,7 @@ private extension ChatManager {
assert(!Thread.isMainThread)
print("[+] starting copilot response for session: \(sessionId)")
let messageParameters: [String: AnyHashable] = [
let messageParameters: AffineGraphQL.JSON = [
// packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
"docs": editorData.documentAttachments.map(\.documentID), // affine doc
"files": [String](), // attachment in context, keep nil for now
@@ -193,18 +194,14 @@ private extension ChatManager {
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
guard let input = try? CreateChatMessageInput(
let input = CreateChatMessageInput(
attachments: [],
blob: attachmentCount == 1 ? "" : .none,
blobs: attachmentCount > 1 && attachmentCount != 0 ? .some([]) : .none,
content: .some(contextSnippet.isEmpty ? editorData.text : "\(contextSnippet)\n\(editorData.text)"),
params: .some(AffineGraphQL.JSON(_jsonValue: messageParameters)),
params: .some(messageParameters),
sessionId: sessionId
) else {
report(sessionId, ChatError.unknownError)
assertionFailure() // very unlikely to happen
return
}
)
let mutation = CreateCopilotMessageMutation(options: input)
QLService.shared.client.upload(operation: mutation, files: uploadableAttachments) { result in
print("[*] createCopilotMessage result: \(result)")
@@ -277,7 +274,7 @@ private extension ChatManager {
let eventSource = EventSource()
let dataTask = eventSource.dataTask(for: request)
var document = ""
self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
await self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
for await event in dataTask.events() {
switch event {
case .open:
@@ -287,7 +284,7 @@ private extension ChatManager {
case let .event(event):
guard let data = event.data else { continue }
document += data
self.writeMarkdownContent(
await self.writeMarkdownContent(
document + loadingIndicator,
sessionId: sessionId,
vmId: vmId
@@ -297,13 +294,13 @@ private extension ChatManager {
print("[*] connection closed")
}
}
self.writeMarkdownContent(document, sessionId: sessionId, vmId: vmId)
await self.writeMarkdownContent(document, sessionId: sessionId, vmId: vmId)
self.closeAll()
}))
self.closable.append(closable)
}
private func writeMarkdownContent(
@MainActor private func writeMarkdownContent(
_ document: String,
sessionId: SessionID,
vmId: UUID
@@ -9,7 +9,7 @@ import Foundation
import MarkdownView
extension IntelligentContext {
func prepareMarkdownViewThemes() {
@MainActor func prepareMarkdownViewThemes() {
MarkdownTheme.default.colors.body = .affineTextPrimary
MarkdownTheme.default.colors.highlight = .affineTextLink
}
@@ -40,7 +40,7 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
var preprocessedContent: MarkdownTextView.PreprocessedContent
init(
@MainActor init(
id: UUID,
content: String,
timestamp: Date,
+6 -6
View File
@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
SPEC CHECKSUMS:
Capacitor: 12914e6f1b7835e161a74ebd19cb361efa37a7dd
CapacitorApp: 63b237168fc869e758481dba283315a85743ee78
CapacitorBrowser: b98aa3db018a2ce4c68242d27e596c344f3b81b3
Capacitor: a5bf59e09f9dd82694fdcca4d107b4d215ac470f
CapacitorApp: 3ddbd30ac18c321531c3da5e707b60873d89dd60
CapacitorBrowser: 66aa8ff09cdca2a327ce464b113b470e6f667753
CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa
CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc
CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62
CapacitorKeyboard: 45cae3956a6f4fb1753f9a4df3e884aeaed8fe82
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
+7
View File
@@ -17,6 +17,7 @@ import {
SubscriptionService,
ValidatorProvider,
} from '@affine/core/modules/cloud';
import { registerNativePreviewHandlers } from '@affine/core/modules/code-block-preview-renderer';
import { DocsService } from '@affine/core/modules/doc';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
@@ -71,6 +72,7 @@ import { Auth } from './plugins/auth';
import { Hashcash } from './plugins/hashcash';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { PayWall } from './plugins/paywall';
import { Preview } from './plugins/preview';
import { writeEndpointToken } from './proxy';
import { enableNavigationGesture$ } from './web-navigation-control';
@@ -215,6 +217,11 @@ framework.impl(NativePaywallProvider, {
const frameworkProvider = framework.provider();
registerNativePreviewHandlers({
renderMermaidSvg: request => Preview.renderMermaidSvg(request),
renderTypstSvg: request => Preview.renderTypstSvg(request),
});
// ------ some apis for native ------
(window as any).getCurrentServerBaseUrl = () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
@@ -0,0 +1,16 @@
export interface PreviewPlugin {
renderMermaidSvg(options: {
code: string;
options?: {
theme?: string;
fontFamily?: string;
fontSize?: number;
};
}): Promise<{ svg: string }>;
renderTypstSvg(options: {
code: string;
options?: {
fontUrls?: string[];
};
}): Promise<{ svg: string }>;
}
@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { PreviewPlugin } from './definitions';
const Preview = registerPlugin<PreviewPlugin>('Preview');
export * from './definitions';
export { Preview };
+3 -1
View File
@@ -47,6 +47,7 @@
"@radix-ui/react-toolbar": "^1.1.1",
"@sentry/react": "^10.40.0",
"@toeverything/infra": "workspace:*",
"@toeverything/mermaid-wasm": "^0.1.0",
"@toeverything/pdf-viewer": "^0.1.1",
"@toeverything/theme": "^1.1.23",
"@vanilla-extract/dynamic": "^2.1.2",
@@ -57,6 +58,7 @@
"cmdk": "^1.0.4",
"core-js": "^3.39.0",
"dayjs": "^1.11.13",
"dompurify": "^3.3.0",
"eventemitter2": "^6.4.9",
"file-type": "^21.0.0",
"filesize": "^10.1.6",
@@ -76,7 +78,7 @@
"lit": "^3.2.1",
"lodash-es": "^4.17.23",
"lottie-react": "^2.4.0",
"mermaid": "^11.12.2",
"mermaid": "^11.13.0",
"mp4-muxer": "^5.2.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.4",
@@ -1,3 +1,4 @@
import { renderMermaidSvg } from '@affine/core/modules/code-block-preview-renderer/bridge';
import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { CodeBlockModel } from '@blocksuite/affine/model';
@@ -7,7 +8,6 @@ import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { Mermaid } from 'mermaid';
export const CodeBlockMermaidPreview = CodeBlockPreviewExtension(
'mermaid',
@@ -154,7 +154,6 @@ export class MermaidPreview extends SignalWatcher(
@query('.mermaid-preview-container')
accessor container!: HTMLDivElement;
private mermaid: Mermaid | null = null;
private retryCount = 0;
private readonly maxRetries = 3;
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -169,9 +168,6 @@ export class MermaidPreview extends SignalWatcher(
private lastMouseY = 0;
override firstUpdated(_changedProperties: PropertyValues): void {
this._loadMermaid().catch(error => {
console.error('Failed to load mermaid in firstUpdated:', error);
});
this._scheduleRender();
this._setupEventListeners();
@@ -271,7 +267,8 @@ export class MermaidPreview extends SignalWatcher(
event.preventDefault();
const delta = event.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.1, Math.min(5, this.scale * delta));
const previousScale = this.scale;
const newScale = Math.max(0.1, Math.min(5, previousScale * delta));
// calculate mouse position relative to container
const rect = this.container.getBoundingClientRect();
@@ -284,8 +281,8 @@ export class MermaidPreview extends SignalWatcher(
// update transform
this.scale = newScale;
this.translateX = mouseX - scaleCenterX * (newScale / this.scale);
this.translateY = mouseY - scaleCenterY * (newScale / this.scale);
this.translateX = mouseX - scaleCenterX * (newScale / previousScale);
this.translateY = mouseY - scaleCenterY * (newScale / previousScale);
this._updateTransform();
};
@@ -309,44 +306,6 @@ export class MermaidPreview extends SignalWatcher(
);
}
private async _loadMermaid() {
try {
// dynamic load mermaid
const mermaidModule = await import('mermaid');
this.mermaid = mermaidModule.default;
// initialize mermaid
this.mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'strict',
fontFamily: 'IBM Plex Mono',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
},
sequence: {
useMaxWidth: true,
},
gantt: {
useMaxWidth: true,
},
pie: {
useMaxWidth: true,
},
journey: {
useMaxWidth: true,
},
gitGraph: {
useMaxWidth: true,
},
});
} catch (error) {
console.error('Failed to load mermaid:', error);
this.state = 'error';
}
}
private async _render() {
// prevent duplicate rendering
if (this.isRendering) {
@@ -356,28 +315,25 @@ export class MermaidPreview extends SignalWatcher(
this.isRendering = true;
this.state = 'loading';
if (!this.normalizedMermaidCode) {
const code = this.normalizedMermaidCode?.trim();
if (!code) {
this.svgContent = '';
this.state = 'fallback';
this.isRendering = false;
return;
}
if (!this.mermaid) {
await this._loadMermaid();
}
if (!this.mermaid) {
return;
}
try {
// generate unique ID
const diagramId = `mermaid-diagram-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// generate SVG
const { svg } = await this.mermaid.render(
diagramId,
this.normalizedMermaidCode
);
const { svg } = await renderMermaidSvg({
code,
options: {
fastText: true,
svgOnly: true,
theme: 'default',
fontFamily: 'IBM Plex Mono',
},
});
// update SVG content
this.svgContent = svg;
@@ -1,3 +1,4 @@
import { renderTypstSvg } from '@affine/core/modules/code-block-preview-renderer/bridge';
import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { CodeBlockModel } from '@blocksuite/affine/model';
@@ -8,8 +9,6 @@ import { property, query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ensureTypstReady, getTypst } from './typst';
const RENDER_DEBOUNCE_MS = 200;
export const CodeBlockTypstPreview = CodeBlockPreviewExtension(
@@ -378,9 +377,7 @@ ${this.errorMessage}</pre
}
try {
await ensureTypstReady();
const typst = await getTypst();
const svg = await typst.svg({ mainContent: code });
const { svg } = await renderTypstSvg({ code });
this.svgContent = svg;
this.state = 'finish';
this._resetView();
@@ -1,57 +0,0 @@
import { $typst, type BeforeBuildFn, loadFonts } from '@myriaddreamin/typst.ts';
const FONT_CDN_URLS = [
'https://cdn.affine.pro/fonts/Inter-Regular.woff',
'https://cdn.affine.pro/fonts/Inter-SemiBold.woff',
'https://cdn.affine.pro/fonts/Inter-Italic.woff',
'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff',
'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf',
] as const;
const getBeforeBuildHooks = (): BeforeBuildFn[] => [
loadFonts([...FONT_CDN_URLS]),
];
const compilerWasmUrl = new URL(
'@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
import.meta.url
).toString();
const rendererWasmUrl = new URL(
'@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
import.meta.url
).toString();
let typstInitPromise: Promise<void> | null = null;
export async function ensureTypstReady() {
if (typstInitPromise) {
return typstInitPromise;
}
typstInitPromise = Promise.resolve()
.then(() => {
$typst.setCompilerInitOptions({
beforeBuild: getBeforeBuildHooks(),
getModule: () => compilerWasmUrl,
});
$typst.setRendererInitOptions({
beforeBuild: getBeforeBuildHooks(),
getModule: () => rendererWasmUrl,
});
})
.catch(error => {
typstInitPromise = null;
throw error;
});
return typstInitPromise;
}
export async function getTypst() {
await ensureTypstReady();
return $typst;
}
export const TYPST_FONT_URLS = FONT_CDN_URLS;
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const { mermaidRender, typstRender } = vi.hoisted(() => ({
mermaidRender: vi.fn(),
typstRender: vi.fn(),
}));
const { domPurifySanitize } = vi.hoisted(() => ({
domPurifySanitize: vi.fn((value: unknown) => {
if (typeof value !== 'string') {
return '';
}
return value.replace(/<script[\s\S]*?<\/script>/gi, '');
}),
}));
vi.mock(
'@affine/core/modules/code-block-preview-renderer/platform-backend',
() => ({
renderMermaidSvgBackend: mermaidRender,
renderTypstSvgBackend: typstRender,
})
);
vi.mock('dompurify', () => ({
default: {
sanitize: domPurifySanitize,
},
}));
import { renderMermaidSvg, renderTypstSvg } from './bridge';
describe('preview render bridge', () => {
beforeEach(() => {
vi.clearAllMocks();
domPurifySanitize.mockImplementation((value: unknown) => {
if (typeof value !== 'string') {
return '';
}
return value.replace(/<script[\s\S]*?<\/script>/gi, '');
});
});
test('uses worker renderers and only sanitizes mermaid output', async () => {
mermaidRender.mockResolvedValue({
svg: '<svg><script>alert(1)</script><text>mermaid</text></svg>',
});
typstRender.mockResolvedValue({
svg: '<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>',
});
const mermaid = await renderMermaidSvg({ code: 'flowchart TD;A-->B' });
const typst = await renderTypstSvg({ code: '= Title' });
expect(mermaidRender).toHaveBeenCalledTimes(1);
expect(typstRender).toHaveBeenCalledTimes(1);
expect(mermaid.svg).toContain('<svg');
expect(mermaid.svg).toContain('mermaid');
expect(mermaid.svg).not.toContain('<script');
expect(typst.svg).toBe(
'<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>'
);
});
test('throws when sanitized svg is empty', async () => {
mermaidRender.mockResolvedValue({
svg: '<div><text>invalid</text></div>',
});
await expect(
renderMermaidSvg({ code: 'flowchart TD;A-->B' })
).rejects.toThrow('Preview renderer returned invalid SVG.');
});
});
@@ -0,0 +1,68 @@
import {
renderMermaidSvgBackend,
renderTypstSvgBackend,
} from '@affine/core/modules/code-block-preview-renderer/platform-backend';
import type {
MermaidRenderRequest,
MermaidRenderResult,
} from '@affine/core/modules/mermaid/renderer';
import type {
TypstRenderRequest,
TypstRenderResult,
} from '@affine/core/modules/typst/renderer';
import DOMPurify from 'dompurify';
function removeForeignObject(root: ParentNode) {
root
.querySelectorAll('foreignObject, foreignobject')
.forEach(element => element.remove());
}
export function sanitizeSvg(svg: string): string {
if (
typeof DOMParser === 'undefined' ||
typeof XMLSerializer === 'undefined'
) {
const sanitized = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
if (typeof sanitized !== 'string' || !/^\s*<svg[\s>]/i.test(sanitized)) {
return '';
}
return sanitized.trim();
}
const parser = new DOMParser();
const parsed = parser.parseFromString(svg, 'image/svg+xml');
const root = parsed.documentElement;
if (!root || root.tagName.toLowerCase() !== 'svg') return '';
const sanitized = DOMPurify.sanitize(root, { USE_PROFILES: { svg: true } });
if (typeof sanitized !== 'string') return '';
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
const sanitizedRoot = sanitizedDoc.documentElement;
if (!sanitizedRoot || sanitizedRoot.tagName.toLowerCase() !== 'svg')
return '';
removeForeignObject(sanitizedRoot);
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
}
export async function renderMermaidSvg(
request: MermaidRenderRequest
): Promise<MermaidRenderResult> {
const rendered = await renderMermaidSvgBackend(request);
const sanitizedSvg = sanitizeSvg(rendered.svg);
if (!sanitizedSvg) {
throw new Error('Preview renderer returned invalid SVG.');
}
return { svg: sanitizedSvg };
}
export async function renderTypstSvg(
request: TypstRenderRequest
): Promise<TypstRenderResult> {
const rendered = await renderTypstSvgBackend(request);
return { svg: rendered.svg };
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const { initialize, render } = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn(),
}));
vi.mock('mermaid', () => ({
default: {
initialize,
render,
},
}));
import { renderClassicMermaidSvg } from './classic-mermaid';
describe('renderClassicMermaidSvg', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('serializes initialize and render across concurrent calls', async () => {
const events: string[] = [];
let releaseFirstRender!: () => void;
initialize.mockImplementation(config => {
events.push(`init:${config.theme}`);
});
render
.mockImplementationOnce(async () => {
events.push('render:first:start');
await new Promise<void>(resolve => {
releaseFirstRender = resolve;
});
events.push('render:first:end');
return { svg: '<svg>first</svg>' };
})
.mockImplementationOnce(async () => {
events.push('render:second:start');
return { svg: '<svg>second</svg>' };
});
const first = renderClassicMermaidSvg({
code: 'flowchart TD;A-->B',
options: { theme: 'default' },
});
const second = renderClassicMermaidSvg({
code: 'flowchart TD;B-->C',
options: { theme: 'modern' },
});
await vi.waitFor(() => {
expect(events).toEqual(['init:default', 'render:first:start']);
});
releaseFirstRender();
await expect(first).resolves.toEqual({ svg: '<svg>first</svg>' });
await expect(second).resolves.toEqual({ svg: '<svg>second</svg>' });
expect(events).toEqual([
'init:default',
'render:first:start',
'render:first:end',
'init:base',
'render:second:start',
]);
});
});
@@ -0,0 +1,62 @@
import type { Mermaid } from 'mermaid';
import type {
MermaidRenderOptions,
MermaidRenderRequest,
MermaidRenderResult,
MermaidRenderTheme,
} from '../mermaid/renderer';
let mermaidPromise: Promise<Mermaid> | null = null;
let mermaidRenderQueue: Promise<void> = Promise.resolve();
function toTheme(theme: MermaidRenderTheme | undefined) {
return theme === 'modern' ? ('base' as const) : ('default' as const);
}
function createClassicMermaidConfig(options?: MermaidRenderOptions) {
return {
startOnLoad: false,
theme: toTheme(options?.theme),
securityLevel: 'strict' as const,
fontFamily: options?.fontFamily ?? 'IBM Plex Mono',
flowchart: { useMaxWidth: true, htmlLabels: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
pie: { useMaxWidth: true },
journey: { useMaxWidth: true },
gitGraph: { useMaxWidth: true },
};
}
async function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import('mermaid').then(module => module.default);
}
return mermaidPromise;
}
function createDiagramId() {
return `mermaid-diagram-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function enqueueClassicMermaidRender<T>(task: () => Promise<T>): Promise<T> {
const run = mermaidRenderQueue.then(task, task);
mermaidRenderQueue = run.then(
() => undefined,
() => undefined
);
return run;
}
export async function renderClassicMermaidSvg(
request: MermaidRenderRequest
): Promise<MermaidRenderResult> {
return enqueueClassicMermaidRender(async () => {
const mermaid = await loadMermaid();
mermaid.initialize(createClassicMermaidConfig(request.options));
const { svg } = await mermaid.render(createDiagramId(), request.code);
return { svg };
});
}
@@ -0,0 +1,14 @@
import type { Framework } from '@toeverything/infra';
import { FeatureFlagService } from '../feature-flag';
import { PreviewRendererFeatureSyncService } from './services/preview-renderer-feature-sync';
export { renderMermaidSvg, renderTypstSvg, sanitizeSvg } from './bridge';
export {
registerNativePreviewHandlers,
setMermaidWasmNativeRendererEnabled,
} from './runtime-config';
export function configureCodeBlockPreviewRendererModule(framework: Framework) {
framework.service(PreviewRendererFeatureSyncService, [FeatureFlagService]);
}
@@ -0,0 +1,52 @@
import { apis } from '@affine/electron-api';
import { renderClassicMermaidSvg } from './classic-mermaid';
import { isMermaidWasmNativeRendererEnabled } from './runtime-config';
import type { PreviewRenderRequestMap, PreviewRenderResultMap } from './types';
type DesktopPreviewHandlers = {
renderMermaidSvg?: (
request: PreviewRenderRequestMap['mermaid']
) => Promise<PreviewRenderResultMap['mermaid']>;
renderTypstSvg?: (
request: PreviewRenderRequestMap['typst']
) => Promise<PreviewRenderResultMap['typst']>;
};
type DesktopPreviewApis = {
preview?: DesktopPreviewHandlers;
};
function getDesktopPreviewHandlers() {
const previewApis = apis as unknown as DesktopPreviewApis;
return previewApis.preview ?? null;
}
function getRequiredDesktopHandler<Name extends keyof DesktopPreviewHandlers>(
name: Name
): NonNullable<DesktopPreviewHandlers[Name]> {
const handlers = getDesktopPreviewHandlers();
const handler = handlers?.[name];
if (!handler) {
throw new Error(
`Electron preview handler "${String(name)}" is unavailable.`
);
}
return handler as NonNullable<DesktopPreviewHandlers[Name]>;
}
export async function renderMermaidSvgBackend(
request: PreviewRenderRequestMap['mermaid']
): Promise<PreviewRenderResultMap['mermaid']> {
if (!isMermaidWasmNativeRendererEnabled()) {
return renderClassicMermaidSvg(request);
}
return getRequiredDesktopHandler('renderMermaidSvg')(request);
}
export async function renderTypstSvgBackend(
request: PreviewRenderRequestMap['typst']
): Promise<PreviewRenderResultMap['typst']> {
return getRequiredDesktopHandler('renderTypstSvg')(request);
}
@@ -0,0 +1,24 @@
import { getNativePreviewHandlers } from './runtime-config';
import type { PreviewRenderRequestMap, PreviewRenderResultMap } from './types';
function getRequiredNativeHandler<
Name extends keyof NonNullable<ReturnType<typeof getNativePreviewHandlers>>,
>(name: Name) {
const handler = getNativePreviewHandlers()?.[name];
if (!handler) {
throw new Error(`Mobile preview handler "${String(name)}" is unavailable.`);
}
return handler;
}
export async function renderMermaidSvgBackend(
request: PreviewRenderRequestMap['mermaid']
): Promise<PreviewRenderResultMap['mermaid']> {
return getRequiredNativeHandler('renderMermaidSvg')(request);
}
export async function renderTypstSvgBackend(
request: PreviewRenderRequestMap['typst']
): Promise<PreviewRenderResultMap['typst']> {
return getRequiredNativeHandler('renderTypstSvg')(request);
}
@@ -0,0 +1,22 @@
import { getMermaidRenderer } from '@affine/core/modules/mermaid/renderer';
import { getTypstRenderer } from '@affine/core/modules/typst/renderer';
import { renderClassicMermaidSvg } from './classic-mermaid';
import { isMermaidWasmNativeRendererEnabled } from './runtime-config';
import type { PreviewRenderRequestMap, PreviewRenderResultMap } from './types';
export async function renderMermaidSvgBackend(
request: PreviewRenderRequestMap['mermaid']
): Promise<PreviewRenderResultMap['mermaid']> {
if (!isMermaidWasmNativeRendererEnabled()) {
return renderClassicMermaidSvg(request);
}
return getMermaidRenderer().render(request);
}
export async function renderTypstSvgBackend(
request: PreviewRenderRequestMap['typst']
): Promise<PreviewRenderResultMap['typst']> {
return getTypstRenderer().render(request);
}
@@ -0,0 +1,39 @@
import type {
MermaidRenderRequest,
MermaidRenderResult,
} from '@affine/core/modules/mermaid/renderer';
import type {
TypstRenderRequest,
TypstRenderResult,
} from '@affine/core/modules/typst/renderer';
type NativePreviewHandlers = {
renderMermaidSvg?: (
request: MermaidRenderRequest
) => Promise<MermaidRenderResult>;
renderTypstSvg?: (request: TypstRenderRequest) => Promise<TypstRenderResult>;
};
let enableMermaidWasmNativeRenderer =
BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid;
let nativePreviewHandlers: NativePreviewHandlers | null = null;
export function setMermaidWasmNativeRendererEnabled(enabled: boolean) {
enableMermaidWasmNativeRenderer = enabled;
}
export function isMermaidWasmNativeRendererEnabled() {
return enableMermaidWasmNativeRenderer;
}
export function registerNativePreviewHandlers(
handlers: NativePreviewHandlers | null
) {
nativePreviewHandlers = handlers;
}
export function getNativePreviewHandlers() {
return nativePreviewHandlers;
}
export type { NativePreviewHandlers };
@@ -0,0 +1,26 @@
import { OnEvent, Service } from '@toeverything/infra';
import { distinctUntilChanged } from 'rxjs';
import type { FeatureFlagService } from '../../feature-flag';
import { ApplicationStarted } from '../../lifecycle';
import { setMermaidWasmNativeRendererEnabled } from '../runtime-config';
@OnEvent(ApplicationStarted, e => e.syncFlag)
export class PreviewRendererFeatureSyncService extends Service {
constructor(private readonly featureFlagService: FeatureFlagService) {
super();
}
syncFlag() {
const mermaidFlag =
this.featureFlagService.flags.enable_mermaid_wasm_native_renderer;
setMermaidWasmNativeRendererEnabled(!!mermaidFlag.value);
const subscription = mermaidFlag.$.pipe(distinctUntilChanged()).subscribe(
enabled => {
setMermaidWasmNativeRendererEnabled(!!enabled);
}
);
this.disposables.push(() => subscription.unsubscribe());
}
}
@@ -0,0 +1,18 @@
import type {
MermaidRenderRequest,
MermaidRenderResult,
} from '@affine/core/modules/mermaid/renderer';
import type {
TypstRenderRequest,
TypstRenderResult,
} from '@affine/core/modules/typst/renderer';
export type PreviewRenderRequestMap = {
mermaid: MermaidRenderRequest;
typst: TypstRenderRequest;
};
export type PreviewRenderResultMap = {
mermaid: MermaidRenderResult;
typst: TypstRenderResult;
};
@@ -4,6 +4,7 @@ import type { FlagInfo } from './types';
const isCanaryBuild = BUILD_CONFIG.appBuildType === 'canary';
const isMobile = BUILD_CONFIG.isMobileEdition;
const isIOS = BUILD_CONFIG.isIOS;
const isAndroid = BUILD_CONFIG.isAndroid;
export const AFFINE_FLAGS = {
enable_ai: {
@@ -203,6 +204,14 @@ export const AFFINE_FLAGS = {
configurable: isMobile && isIOS,
defaultState: isMobile && isIOS,
},
enable_mermaid_wasm_native_renderer: {
category: 'affine',
displayName: 'Enable Native Mermaid Renderer',
description:
'Use the new Mermaid renderer backend. Web uses WASM, desktop uses native, and mobile always uses native. The native renderer is more than 10x faster, but its styling/aesthetic quality and the types of graphics it supports are not as good as the JS version.',
configurable: !isIOS && !isAndroid,
defaultState: isIOS || isAndroid,
},
enable_turbo_renderer: {
category: 'blocksuite',
bsFlag: 'enable_turbo_renderer',
@@ -13,6 +13,7 @@ import { configureAppSidebarModule } from './app-sidebar';
import { configAtMenuConfigModule } from './at-menu-config';
import { configureBlobManagementModule } from './blob-management';
import { configureCloudModule } from './cloud';
import { configureCodeBlockPreviewRendererModule } from './code-block-preview-renderer';
import { configureCollectionModule } from './collection';
import { configureCollectionRulesModule } from './collection-rules';
import { configureCommentModule } from './comment';
@@ -77,6 +78,7 @@ export function configureCommonModules(framework: Framework) {
configureGlobalContextModule(framework);
configureLifecycleModule(framework);
configureFeatureFlagModule(framework);
configureCodeBlockPreviewRendererModule(framework);
configureCollectionModule(framework);
configureNavigationModule(framework);
configureTagModule(framework);
@@ -0,0 +1,39 @@
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
import type {
MermaidOps,
MermaidRenderOptions,
MermaidRenderRequest,
} from './types';
class MermaidRenderer extends WorkerOpRenderer<MermaidOps> {
constructor() {
super('mermaid');
}
init(options?: MermaidRenderOptions) {
return this.ensureInitialized(() => this.call('init', options));
}
async render(request: MermaidRenderRequest) {
await this.init();
return this.call('render', request);
}
}
let sharedMermaidRenderer: MermaidRenderer | null = null;
export function getMermaidRenderer() {
if (!sharedMermaidRenderer) {
sharedMermaidRenderer = new MermaidRenderer();
}
return sharedMermaidRenderer;
}
export type {
MermaidOps,
MermaidRenderOptions,
MermaidRenderRequest,
MermaidRenderResult,
MermaidRenderTheme,
MermaidTextMetrics,
} from './types';
@@ -0,0 +1,63 @@
import type { MessageCommunicapable } from '@toeverything/infra/op';
import { OpConsumer } from '@toeverything/infra/op';
import initMmdr, { render_mermaid_svg } from '@toeverything/mermaid-wasm';
import type {
MermaidOps,
MermaidRenderOptions,
MermaidRenderRequest,
} from './types';
const DEFAULT_RENDER_OPTIONS: MermaidRenderOptions = {
fastText: true,
svgOnly: true,
theme: 'modern',
fontFamily: 'IBM Plex Mono',
};
function mergeOptions(
base: MermaidRenderOptions,
override: MermaidRenderOptions | undefined
): MermaidRenderOptions {
if (!override) {
return base;
}
return {
...base,
...override,
textMetrics: override.textMetrics ?? base.textMetrics,
};
}
class MermaidRendererBackend extends OpConsumer<MermaidOps> {
private initPromise: Promise<void> | null = null;
private options: MermaidRenderOptions = DEFAULT_RENDER_OPTIONS;
constructor(port: MessageCommunicapable) {
super(port);
this.register('init', this.init.bind(this));
this.register('render', this.render.bind(this));
}
private ensureReady() {
if (!this.initPromise) {
this.initPromise = initMmdr().then(() => undefined);
}
return this.initPromise;
}
async init(options?: MermaidRenderOptions) {
this.options = mergeOptions(DEFAULT_RENDER_OPTIONS, options);
await this.ensureReady();
return { ok: true } as const;
}
async render({ code, options }: MermaidRenderRequest) {
await this.ensureReady();
const mergedOptions = mergeOptions(this.options, options);
const svg = render_mermaid_svg(code, JSON.stringify(mergedOptions));
return { svg };
}
}
new MermaidRendererBackend(self as MessageCommunicapable);
@@ -0,0 +1,32 @@
import type { OpSchema } from '@toeverything/infra/op';
export type MermaidTextMetrics = {
ascii: number;
cjk: number;
space: number;
};
export type MermaidRenderTheme = 'modern' | 'default';
export type MermaidRenderOptions = {
fastText?: boolean;
svgOnly?: boolean;
textMetrics?: MermaidTextMetrics;
theme?: MermaidRenderTheme;
fontFamily?: string;
fontSize?: number;
};
export type MermaidRenderRequest = {
code: string;
options?: MermaidRenderOptions;
};
export type MermaidRenderResult = {
svg: string;
};
export interface MermaidOps extends OpSchema {
init: [MermaidRenderOptions | undefined, { ok: true }];
render: [MermaidRenderRequest, MermaidRenderResult];
}
@@ -1,2 +1,10 @@
export { PDFRenderer } from './renderer';
export type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
import type { PDFOps } from './types';
export class PDFRenderer extends WorkerOpRenderer<PDFOps> {
constructor() {
super('pdf');
}
}
export type { PDFMeta, PDFOps, RenderedPage, RenderPageOpts } from './types';
@@ -1,8 +0,0 @@
import type { OpSchema } from '@toeverything/infra/op';
import type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
export interface ClientOps extends OpSchema {
open: [{ data: ArrayBuffer }, PDFMeta];
render: [RenderPageOpts, RenderedPage];
}
@@ -23,10 +23,9 @@ import {
switchMap,
} from 'rxjs';
import type { ClientOps } from './ops';
import type { PDFMeta, RenderPageOpts } from './types';
import type { PDFMeta, PDFOps, RenderPageOpts } from './types';
class PDFRendererBackend extends OpConsumer<ClientOps> {
class PDFRendererBackend extends OpConsumer<PDFOps> {
constructor(port: MessageCommunicapable) {
super(port);
this.register('open', this.open.bind(this));
@@ -1,24 +0,0 @@
import { getWorkerUrl } from '@affine/env/worker';
import { OpClient } from '@toeverything/infra/op';
import type { ClientOps } from './ops';
export class PDFRenderer extends OpClient<ClientOps> {
private readonly worker: Worker;
constructor() {
const worker = new Worker(getWorkerUrl('pdf'));
super(worker);
this.worker = worker;
}
override destroy() {
super.destroy();
this.worker.terminate();
}
[Symbol.dispose]() {
this.destroy();
}
}
@@ -1,3 +1,5 @@
import type { OpSchema } from '@toeverything/infra/op';
export type PageSize = {
width: number;
height: number;
@@ -21,3 +23,8 @@ export type RenderPageOpts = {
export type RenderedPage = {
bitmap: ImageBitmap;
};
export interface PDFOps extends OpSchema {
open: [{ data: ArrayBuffer }, PDFMeta];
render: [RenderPageOpts, RenderedPage];
}
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { WorkerOpRenderer } from './worker-op-renderer';
vi.mock('@affine/env/worker', () => ({
getWorkerUrl: vi.fn(() => '/worker.js'),
}));
class MockWorker {
addEventListener = vi.fn();
postMessage = vi.fn();
removeEventListener = vi.fn();
terminate = vi.fn();
}
class TestRenderer extends WorkerOpRenderer<{
init: [undefined, { ok: true }];
}> {
constructor() {
super('test');
}
init() {
return this.ensureInitialized(async () => {
return { ok: true } as const;
});
}
}
describe('WorkerOpRenderer', () => {
beforeEach(() => {
vi.stubGlobal('Worker', MockWorker);
});
test('rejects initialization after destroy', async () => {
const renderer = new TestRenderer();
renderer.destroy();
await expect(renderer.init()).rejects.toThrow('renderer destroyed');
});
});
@@ -0,0 +1,47 @@
import { getWorkerUrl } from '@affine/env/worker';
import { OpClient, type OpSchema } from '@toeverything/infra/op';
type InitTask = () => Promise<unknown>;
export abstract class WorkerOpRenderer<
Ops extends OpSchema,
> extends OpClient<Ops> {
private readonly worker: Worker;
private destroyed = false;
private initPromise: Promise<void> | null = null;
protected constructor(workerName: string) {
const worker = new Worker(getWorkerUrl(workerName));
super(worker);
this.worker = worker;
}
protected ensureInitialized(task: InitTask) {
if (this.destroyed) return Promise.reject(new Error('renderer destroyed'));
if (!this.initPromise) {
this.initPromise = task()
.then(() => undefined)
.catch(error => {
this.initPromise = null;
throw error;
});
}
return this.initPromise;
}
protected resetInitialization() {
this.initPromise = null;
}
override destroy() {
if (this.destroyed) return;
this.destroyed = true;
super.destroy();
this.worker.terminate();
this.resetInitialization();
}
[Symbol.dispose]() {
this.destroy();
}
}
@@ -0,0 +1,33 @@
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
import type { TypstOps, TypstRenderOptions, TypstRenderRequest } from './types';
class TypstRenderer extends WorkerOpRenderer<TypstOps> {
constructor() {
super('typst');
}
init(options?: TypstRenderOptions) {
return this.ensureInitialized(() => this.call('init', options));
}
async render(request: TypstRenderRequest) {
await this.init();
return this.call('render', request);
}
}
let sharedTypstRenderer: TypstRenderer | null = null;
export function getTypstRenderer() {
if (!sharedTypstRenderer) {
sharedTypstRenderer = new TypstRenderer();
}
return sharedTypstRenderer;
}
export type {
TypstOps,
TypstRenderOptions,
TypstRenderRequest,
TypstRenderResult,
} from './types';
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const { loadFonts, setCompilerInitOptions, setRendererInitOptions, svg } =
vi.hoisted(() => ({
loadFonts: vi.fn((fontUrls: string[]) => ({ fontUrls })),
setCompilerInitOptions: vi.fn(),
setRendererInitOptions: vi.fn(),
svg: vi.fn(),
}));
vi.mock('@myriaddreamin/typst.ts', () => ({
$typst: {
setCompilerInitOptions,
setRendererInitOptions,
svg,
},
loadFonts,
}));
import { ensureTypstReady, renderTypstSvgWithOptions } from './runtime';
describe('typst runtime', () => {
beforeEach(() => {
vi.clearAllMocks();
svg.mockResolvedValue('<svg />');
});
test('reconfigures typst when fontUrls change', async () => {
await ensureTypstReady(['font-a']);
await ensureTypstReady(['font-b']);
expect(loadFonts).toHaveBeenNthCalledWith(
1,
['font-a'],
expect.any(Object)
);
expect(loadFonts).toHaveBeenNthCalledWith(
2,
['font-b'],
expect.any(Object)
);
expect(setCompilerInitOptions).toHaveBeenCalledTimes(2);
expect(setRendererInitOptions).toHaveBeenCalledTimes(2);
});
test('serializes typst renders that need different configuration', async () => {
const events: string[] = [];
let releaseFirstRender!: () => void;
svg.mockImplementationOnce(async () => {
events.push('svg:first:start');
await new Promise<void>(resolve => {
releaseFirstRender = resolve;
});
events.push('svg:first:end');
return '<svg>first</svg>';
});
svg.mockImplementationOnce(async () => {
events.push('svg:second:start');
return '<svg>second</svg>';
});
const first = renderTypstSvgWithOptions('= First', {
fontUrls: ['font-a'],
});
const second = renderTypstSvgWithOptions('= Second', {
fontUrls: ['font-b'],
});
await vi.waitFor(() => {
expect(events).toEqual(['svg:first:start']);
});
releaseFirstRender();
await expect(first).resolves.toEqual({ svg: '<svg>first</svg>' });
await expect(second).resolves.toEqual({ svg: '<svg>second</svg>' });
expect(events).toEqual([
'svg:first:start',
'svg:first:end',
'svg:second:start',
]);
});
});
@@ -0,0 +1,209 @@
import { $typst, type BeforeBuildFn, loadFonts } from '@myriaddreamin/typst.ts';
import type { TypstRenderOptions } from './types';
export const DEFAULT_TYPST_FONT_URLS = [
'https://cdn.affine.pro/fonts/Inter-Regular.woff',
'https://cdn.affine.pro/fonts/Inter-SemiBold.woff',
'https://cdn.affine.pro/fonts/Inter-Italic.woff',
'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff',
'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf',
] as const;
export const DEFAULT_TYPST_RENDER_OPTIONS: TypstRenderOptions = {
fontUrls: [...DEFAULT_TYPST_FONT_URLS],
};
const DEFAULT_FONT_FALLBACKS: Record<string, string> = {
'Inter-Regular.woff': 'Inter-Regular.woff2',
'Inter-SemiBold.woff': 'Inter-SemiBold.woff2',
'Inter-Italic.woff': 'Inter-Italic.woff2',
'Inter-SemiBoldItalic.woff': 'Inter-SemiBoldItalic.woff2',
'SarasaGothicCL-Regular.ttf': 'Inter-Regular.woff2',
'Inter-Regular.woff2': 'Inter-Regular.woff2',
'Inter-SemiBold.woff2': 'Inter-SemiBold.woff2',
'Inter-Italic.woff2': 'Inter-Italic.woff2',
'Inter-SemiBoldItalic.woff2': 'Inter-SemiBoldItalic.woff2',
};
const compilerWasmUrl = new URL(
'@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
import.meta.url
).toString();
const rendererWasmUrl = new URL(
'@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
import.meta.url
).toString();
type TypstWasmModuleUrls = {
compilerWasmUrl?: string;
rendererWasmUrl?: string;
};
type TypstInitState = {
key: string;
promise: Promise<void>;
};
let typstInitState: TypstInitState | null = null;
let typstRenderQueue: Promise<void> = Promise.resolve();
function extractInputUrl(input: RequestInfo | URL): string | null {
if (input instanceof URL) {
return input.toString();
}
if (typeof input === 'string') {
return input;
}
if (typeof Request !== 'undefined' && input instanceof Request) {
return input.url;
}
return null;
}
function resolveLocalFallbackFontUrl(sourceUrl: string): string | null {
if (typeof location === 'undefined') {
return null;
}
const source = new URL(sourceUrl, location.href);
const fileName = source.pathname.split('/').at(-1);
if (!fileName) {
return null;
}
const fallbackFileName = DEFAULT_FONT_FALLBACKS[fileName];
if (!fallbackFileName) {
return null;
}
const workerUrl = new URL(location.href);
const jsPathMarker = '/js/';
const markerIndex = workerUrl.pathname.lastIndexOf(jsPathMarker);
const basePath =
markerIndex >= 0 ? workerUrl.pathname.slice(0, markerIndex + 1) : '/';
return new URL(
`${basePath}fonts/${fallbackFileName}`,
workerUrl.origin
).toString();
}
export function createTypstFontFetcher(baseFetcher: typeof fetch = fetch) {
return async (input: RequestInfo | URL, init?: RequestInit) => {
const sourceUrl = extractInputUrl(input);
const fallbackUrl = sourceUrl
? resolveLocalFallbackFontUrl(sourceUrl)
: null;
try {
const response = await baseFetcher(input, init);
if (!fallbackUrl || response.ok || fallbackUrl === sourceUrl) {
return response;
}
const fallbackResponse = await baseFetcher(fallbackUrl, init);
return fallbackResponse.ok ? fallbackResponse : response;
} catch (error) {
if (!fallbackUrl || fallbackUrl === sourceUrl) {
throw error;
}
return baseFetcher(fallbackUrl, init);
}
};
}
export function mergeTypstRenderOptions(
base: TypstRenderOptions,
override: TypstRenderOptions | undefined
): TypstRenderOptions {
return {
...base,
...override,
fontUrls: override?.fontUrls ?? base.fontUrls,
};
}
function getBeforeBuildHooks(fontUrls: string[]): BeforeBuildFn[] {
return [
loadFonts([...fontUrls], {
assets: ['text'],
fetcher: createTypstFontFetcher(),
}),
];
}
function createTypstInitKey(
fontUrls: string[],
wasmModuleUrls: TypstWasmModuleUrls
) {
return JSON.stringify({
fontUrls,
compilerWasmUrl: wasmModuleUrls.compilerWasmUrl ?? compilerWasmUrl,
rendererWasmUrl: wasmModuleUrls.rendererWasmUrl ?? rendererWasmUrl,
});
}
function enqueueTypstRender<T>(task: () => Promise<T>): Promise<T> {
const run = typstRenderQueue.then(task, task);
typstRenderQueue = run.then(
() => undefined,
() => undefined
);
return run;
}
export async function ensureTypstReady(
fontUrls: string[],
wasmModuleUrls: TypstWasmModuleUrls = {}
) {
const key = createTypstInitKey(fontUrls, wasmModuleUrls);
if (typstInitState?.key === key) {
return typstInitState.promise;
}
const promise = Promise.resolve()
.then(() => {
const compilerBeforeBuild = getBeforeBuildHooks(fontUrls);
$typst.setCompilerInitOptions({
beforeBuild: compilerBeforeBuild,
getModule: () => wasmModuleUrls.compilerWasmUrl ?? compilerWasmUrl,
});
$typst.setRendererInitOptions({
getModule: () => wasmModuleUrls.rendererWasmUrl ?? rendererWasmUrl,
});
})
.catch(error => {
if (typstInitState?.key === key) {
typstInitState = null;
}
throw error;
});
typstInitState = { key, promise };
return promise;
}
export async function renderTypstSvgWithOptions(
code: string,
options: TypstRenderOptions | undefined,
wasmModuleUrls?: TypstWasmModuleUrls
) {
const resolvedOptions = mergeTypstRenderOptions(
DEFAULT_TYPST_RENDER_OPTIONS,
options
);
return enqueueTypstRender(async () => {
await ensureTypstReady(
resolvedOptions.fontUrls ?? [...DEFAULT_TYPST_FONT_URLS],
wasmModuleUrls
);
const svg = await $typst.svg({
mainContent: code,
});
return { svg };
});
}
@@ -0,0 +1,19 @@
import type { OpSchema } from '@toeverything/infra/op';
export type TypstRenderOptions = {
fontUrls?: string[];
};
export type TypstRenderRequest = {
code: string;
options?: TypstRenderOptions;
};
export type TypstRenderResult = {
svg: string;
};
export interface TypstOps extends OpSchema {
init: [TypstRenderOptions | undefined, { ok: true }];
render: [TypstRenderRequest, TypstRenderResult];
}
@@ -0,0 +1,40 @@
import type { MessageCommunicapable } from '@toeverything/infra/op';
import { OpConsumer } from '@toeverything/infra/op';
import {
DEFAULT_TYPST_RENDER_OPTIONS,
ensureTypstReady,
mergeTypstRenderOptions,
renderTypstSvgWithOptions,
} from './runtime';
import type { TypstOps, TypstRenderOptions, TypstRenderRequest } from './types';
class TypstRendererBackend extends OpConsumer<TypstOps> {
private options: TypstRenderOptions = DEFAULT_TYPST_RENDER_OPTIONS;
constructor(port: MessageCommunicapable) {
super(port);
this.register('init', this.init.bind(this));
this.register('render', this.render.bind(this));
}
async init(options?: TypstRenderOptions) {
this.options = mergeTypstRenderOptions(
DEFAULT_TYPST_RENDER_OPTIONS,
options
);
await ensureTypstReady(
this.options.fontUrls ?? [
...(DEFAULT_TYPST_RENDER_OPTIONS.fontUrls ?? []),
]
);
return { ok: true } as const;
}
async render({ code, options }: TypstRenderRequest) {
const mergedOptions = mergeTypstRenderOptions(this.options, options);
return renderTypstSvgWithOptions(code, mergedOptions);
}
}
new TypstRendererBackend(self as MessageCommunicapable);
+6 -1
View File
@@ -1,6 +1,7 @@
[package]
edition = "2024"
name = "affine_mobile_native"
publish = false
version = "0.0.0"
[lib]
@@ -40,7 +41,11 @@ objc2-foundation = { workspace = true, features = [
homedir = { workspace = true }
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
lru = { workspace = true }
lru = { workspace = true }
mermaid-rs-renderer = { workspace = true }
typst = { workspace = true }
typst-as-lib = { workspace = true }
typst-svg = { workspace = true }
[build-dependencies]
uniffi = { workspace = true, features = ["build"] }
@@ -1,6 +1,8 @@
mod error;
mod ffi_types;
mod payload_codec;
#[cfg(any(target_os = "android", target_os = "ios"))]
mod preview;
mod storage;
#[cfg(test)]
mod tests;
@@ -14,6 +16,8 @@ pub use error::UniffiError;
pub use ffi_types::{
Blob, BlockInfo, CrawlResult, DocClock, DocRecord, DocUpdate, ListedBlob, MatchRange, SearchHit, SetBlob,
};
#[cfg(any(target_os = "android", target_os = "ios"))]
pub use preview::{render_mermaid_preview_svg, render_typst_preview_svg};
pub use storage::{DocStoragePool, new_doc_storage_pool};
uniffi::setup_scaffolding!("affine_mobile_native");
@@ -0,0 +1,155 @@
use std::{borrow::Cow, path::PathBuf};
use mermaid_rs_renderer::RenderOptions;
use typst::{
diag::FileResult,
foundations::Bytes,
layout::{Abs, PagedDocument},
syntax::{FileId, Source},
};
use typst_as_lib::{
TypstEngine,
cached_file_resolver::{CachedFileResolver, IntoCachedFileResolver},
file_resolver::FileResolver,
package_resolver::{FileSystemCache, PackageResolver},
typst_kit_options::TypstKitFontOptions,
};
use crate::{Result, UniffiError};
const TYPST_PACKAGE_CACHE_DIR: &str = "typst-package-cache";
enum MobileTypstPackageResolver {
FileSystem(CachedFileResolver<PackageResolver<FileSystemCache>>),
InMemory(CachedFileResolver<PackageResolver<typst_as_lib::package_resolver::InMemoryCache>>),
}
impl FileResolver for MobileTypstPackageResolver {
fn resolve_binary(&self, id: FileId) -> FileResult<Cow<'_, Bytes>> {
match self {
Self::FileSystem(resolver) => resolver.resolve_binary(id),
Self::InMemory(resolver) => resolver.resolve_binary(id),
}
}
fn resolve_source(&self, id: FileId) -> FileResult<Cow<'_, Source>> {
match self {
Self::FileSystem(resolver) => resolver.resolve_source(id),
Self::InMemory(resolver) => resolver.resolve_source(id),
}
}
}
fn resolve_mermaid_render_options(
theme: Option<String>,
font_family: Option<String>,
font_size: Option<f64>,
) -> RenderOptions {
let mut render_options = match theme.as_deref() {
Some("default") => RenderOptions::mermaid_default(),
_ => RenderOptions::modern(),
};
if let Some(font_family) = font_family {
render_options.theme.font_family = font_family;
}
if let Some(font_size) = font_size {
render_options.theme.font_size = font_size as f32;
}
render_options
}
#[uniffi::export]
pub fn render_mermaid_preview_svg(
code: String,
theme: Option<String>,
font_family: Option<String>,
font_size: Option<f64>,
) -> Result<String> {
let render_options = resolve_mermaid_render_options(theme, font_family, font_size);
mermaid_rs_renderer::render_with_options(&code, render_options).map_err(|error| UniffiError::Err(error.to_string()))
}
fn normalize_typst_svg(svg: String) -> String {
let mut svg = svg;
let page_background_marker = r##"<path class="typst-shape""##;
let mut cursor = 0;
while let Some(relative_idx) = svg[cursor..].find(page_background_marker) {
let idx = cursor + relative_idx;
let rest = &svg[idx..];
let Some(relative_end) = rest.find("/>") else {
break;
};
let end = idx + relative_end + 2;
let path_fragment = &svg[idx..end];
let is_page_background_path =
path_fragment.contains(r#"d="M 0 0v "#) && path_fragment.contains(r#" h "#) && path_fragment.contains(r#" v -"#);
if is_page_background_path {
svg.replace_range(idx..end, "");
cursor = idx;
continue;
}
cursor = end;
}
svg
}
fn resolve_typst_font_dirs(font_dirs: Option<Vec<String>>) -> Vec<PathBuf> {
font_dirs
.map(|dirs| dirs.into_iter().map(PathBuf::from).collect())
.unwrap_or_default()
}
fn resolve_typst_package_resolver(cache_dir: Option<String>) -> Result<MobileTypstPackageResolver> {
let resolver = match cache_dir {
Some(cache_dir) => {
let cache_dir = PathBuf::from(cache_dir).join(TYPST_PACKAGE_CACHE_DIR);
std::fs::create_dir_all(&cache_dir).map_err(|error| UniffiError::Err(error.to_string()))?;
MobileTypstPackageResolver::FileSystem(
PackageResolver::builder()
.cache(FileSystemCache(cache_dir))
.build()
.into_cached(),
)
}
None => {
MobileTypstPackageResolver::InMemory(PackageResolver::builder().with_in_memory_cache().build().into_cached())
}
};
Ok(resolver)
}
#[uniffi::export]
pub fn render_typst_preview_svg(
code: String,
font_dirs: Option<Vec<String>>,
cache_dir: Option<String>,
) -> Result<String> {
let search_options = TypstKitFontOptions::new()
.include_system_fonts(false)
.include_embedded_fonts(true)
.include_dirs(resolve_typst_font_dirs(font_dirs));
let package_resolver = resolve_typst_package_resolver(cache_dir)?;
let engine = TypstEngine::builder()
.main_file(code)
.search_fonts_with(search_options)
.add_file_resolver(package_resolver)
.build();
let document = engine
.compile::<PagedDocument>()
.output
.map_err(|error| UniffiError::Err(error.to_string()))?;
Ok(normalize_typst_svg(typst_svg::svg_merged(&document, Abs::pt(0.0))))
}
+7
View File
@@ -1,6 +1,7 @@
[package]
edition = "2024"
name = "affine_native"
publish = false
version = "0.0.0"
[lib]
@@ -25,6 +26,12 @@ sqlx = { workspace = true, default-features = false, features = [
thiserror = { workspace = true }
tokio = { workspace = true, features = ["full"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
mermaid-rs-renderer = { workspace = true }
typst = { workspace = true }
typst-as-lib = { workspace = true }
typst-svg = { workspace = true }
[target.'cfg(not(target_os = "linux"))'.dependencies]
mimalloc = { workspace = true }
+33
View File
@@ -40,8 +40,41 @@ export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | u
/** Decode audio file into a Float32Array */
export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array
export interface MermaidRenderOptions {
theme?: string
fontFamily?: string
fontSize?: number
}
export interface MermaidRenderRequest {
code: string
options?: MermaidRenderOptions
}
export interface MermaidRenderResult {
svg: string
}
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
export declare function renderMermaidSvg(request: MermaidRenderRequest): MermaidRenderResult
export declare function renderTypstSvg(request: TypstRenderRequest): TypstRenderResult
export interface TypstRenderOptions {
fontUrls?: Array<string>
fontDirs?: Array<string>
}
export interface TypstRenderRequest {
code: string
options?: TypstRenderOptions
}
export interface TypstRenderResult {
svg: string
}
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
export declare class DocStorage {
constructor(path: string)
+2
View File
@@ -580,6 +580,8 @@ module.exports.ShareableContent = nativeBinding.ShareableContent
module.exports.decodeAudio = nativeBinding.decodeAudio
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync
module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse
module.exports.renderMermaidSvg = nativeBinding.renderMermaidSvg
module.exports.renderTypstSvg = nativeBinding.renderTypstSvg
module.exports.verifyChallengeResponse = nativeBinding.verifyChallengeResponse
module.exports.DocStorage = nativeBinding.DocStorage
module.exports.DocStoragePool = nativeBinding.DocStoragePool
@@ -1,6 +1,7 @@
[package]
edition = "2024"
name = "affine_media_capture"
publish = false
version = "0.0.0"
[lib]
@@ -1,6 +1,7 @@
[package]
edition = "2024"
name = "affine_nbstore"
publish = false
version = "0.0.0"
[lib]
@@ -1,6 +1,7 @@
[package]
edition = "2024"
name = "affine_schema"
publish = false
version = "0.0.0"
[dependencies]
@@ -1,6 +1,7 @@
[package]
edition = "2024"
name = "affine_sqlite_v1"
publish = false
version = "0.0.0"
[lib]
+2
View File
@@ -1,4 +1,6 @@
pub mod hashcash;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod preview;
#[cfg(not(target_arch = "arm"))]
#[global_allocator]
+191
View File
@@ -0,0 +1,191 @@
use std::path::PathBuf;
use mermaid_rs_renderer::RenderOptions;
use napi::{Error, Result};
use napi_derive::napi;
use typst::layout::{Abs, PagedDocument};
use typst_as_lib::{TypstEngine, typst_kit_options::TypstKitFontOptions};
#[napi(object)]
pub struct MermaidRenderOptions {
pub theme: Option<String>,
pub font_family: Option<String>,
pub font_size: Option<f64>,
}
#[napi(object)]
pub struct MermaidRenderRequest {
pub code: String,
pub options: Option<MermaidRenderOptions>,
}
#[napi(object)]
pub struct MermaidRenderResult {
pub svg: String,
}
fn resolve_mermaid_render_options(options: Option<MermaidRenderOptions>) -> RenderOptions {
let mut render_options = match options.as_ref().and_then(|options| options.theme.as_deref()) {
Some("default") => RenderOptions::mermaid_default(),
_ => RenderOptions::modern(),
};
if let Some(options) = options {
if let Some(font_family) = options.font_family {
render_options.theme.font_family = font_family;
}
if let Some(font_size) = options.font_size {
render_options.theme.font_size = font_size as f32;
}
}
render_options
}
#[napi]
pub fn render_mermaid_svg(request: MermaidRenderRequest) -> Result<MermaidRenderResult> {
let render_options = resolve_mermaid_render_options(request.options);
let svg = mermaid_rs_renderer::render_with_options(&request.code, render_options)
.map_err(|error| Error::from_reason(error.to_string()))?;
Ok(MermaidRenderResult { svg })
}
#[napi(object)]
pub struct TypstRenderOptions {
pub font_urls: Option<Vec<String>>,
pub font_dirs: Option<Vec<String>>,
}
#[napi(object)]
pub struct TypstRenderRequest {
pub code: String,
pub options: Option<TypstRenderOptions>,
}
#[napi(object)]
pub struct TypstRenderResult {
pub svg: String,
}
fn resolve_local_font_dir(value: &str) -> Option<PathBuf> {
let path = if let Some(stripped) = value.strip_prefix("file://") {
PathBuf::from(stripped)
} else {
let path = PathBuf::from(value);
if !path.is_absolute() {
return None;
}
path
};
if path.is_dir() {
return Some(path);
}
path.parent().map(|parent| parent.to_path_buf())
}
fn resolve_typst_font_dirs(options: &Option<TypstRenderOptions>) -> Vec<PathBuf> {
let Some(options) = options.as_ref() else {
return Vec::new();
};
let mut font_dirs = options
.font_dirs
.as_ref()
.map(|dirs| dirs.iter().map(PathBuf::from).collect::<Vec<_>>())
.unwrap_or_default();
if let Some(font_urls) = options.font_urls.as_ref() {
font_dirs.extend(font_urls.iter().filter_map(|url| resolve_local_font_dir(url)));
}
font_dirs
}
fn normalize_typst_svg(svg: String) -> String {
let mut svg = svg;
let page_background_marker = r##"<path class="typst-shape""##;
let mut cursor = 0;
while let Some(relative_idx) = svg[cursor..].find(page_background_marker) {
let idx = cursor + relative_idx;
let rest = &svg[idx..];
let Some(relative_end) = rest.find("/>") else {
break;
};
let end = idx + relative_end + 2;
let path_fragment = &svg[idx..end];
let is_page_background_path =
path_fragment.contains(r#"d="M 0 0v "#) && path_fragment.contains(r#" h "#) && path_fragment.contains(r#" v -"#);
if is_page_background_path {
svg.replace_range(idx..end, "");
cursor = idx;
continue;
}
cursor = end;
}
svg
}
#[napi]
pub fn render_typst_svg(request: TypstRenderRequest) -> Result<TypstRenderResult> {
let font_dirs = resolve_typst_font_dirs(&request.options);
let search_options = TypstKitFontOptions::new()
.include_system_fonts(false)
.include_embedded_fonts(true)
.include_dirs(font_dirs);
let engine = TypstEngine::builder()
.main_file(request.code)
.search_fonts_with(search_options)
.with_package_file_resolver()
.build();
let document = engine
.compile::<PagedDocument>()
.output
.map_err(|error| Error::from_reason(error.to_string()))?;
let svg = normalize_typst_svg(typst_svg::svg_merged(&document, Abs::pt(0.0)));
Ok(TypstRenderResult { svg })
}
#[cfg(test)]
mod tests {
use super::normalize_typst_svg;
#[test]
fn normalize_typst_svg_removes_all_backgrounds() {
let input = r##"<svg>
<path class="typst-shape" fill="#ffffff" fill-rule="nonzero" d="M 0 0v 10 h 10 v -10 Z "/>
<g></g>
<path class="typst-shape" fill="#ffffff" fill-rule="nonzero" d="M 0 0v 10 h 10 v -10 Z "/>
<g transform="matrix(1 0 0 1 0 10)"></g>
</svg>"##
.to_string();
let normalized = normalize_typst_svg(input);
let retained = normalized
.matches(r##"<path class="typst-shape" fill="#ffffff" fill-rule="nonzero""##)
.count();
assert_eq!(retained, 0);
}
#[test]
fn normalize_typst_svg_keeps_non_background_paths() {
let input = r##"<svg>
<path class="typst-shape" fill="#000000" fill-rule="nonzero" d="M 1 2 L 3 4 Z "/>
</svg>"##
.to_string();
let normalized = normalize_typst_svg(input);
assert!(normalized.contains(r##"d="M 1 2 L 3 4 Z ""##));
}
}
+49 -6
View File
@@ -27,6 +27,9 @@ import {
type WorkerConfig = { name: string };
type CreateWorkerTargetConfig = (pkg: Package, entry: string) => WorkerConfig;
type BaseWorkerOptions = {
includeMermaidAndTypst?: boolean;
};
function assertRspackSupportedPackage(pkg: Package) {
assertRspackSupportedPackageName(pkg.name);
@@ -49,11 +52,13 @@ async function uploadAssetsForPackage(pkg: Package, logger: Logger) {
function getBaseWorkerConfigs(
pkg: Package,
createWorkerTargetConfig: CreateWorkerTargetConfig
createWorkerTargetConfig: CreateWorkerTargetConfig,
options: BaseWorkerOptions = {}
) {
const core = new Package('@affine/core');
const includeMermaidAndTypst = options.includeMermaidAndTypst ?? true;
return [
const workerConfigs = [
createWorkerTargetConfig(
pkg,
core.srcPath.join(
@@ -71,6 +76,21 @@ function getBaseWorkerConfigs(
).value
),
];
if (includeMermaidAndTypst) {
workerConfigs.push(
createWorkerTargetConfig(
pkg,
core.srcPath.join('modules/mermaid/renderer/mermaid.worker.ts').value
),
createWorkerTargetConfig(
pkg,
core.srcPath.join('modules/typst/renderer/typst.worker.ts').value
)
);
}
return workerConfigs;
}
function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
@@ -85,9 +105,7 @@ function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
] as MultiRspackOptions;
}
case '@affine/web':
case '@affine/mobile':
case '@affine/ios':
case '@affine/android': {
case '@affine/mobile': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig
@@ -109,10 +127,35 @@ function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
...workerConfigs,
] as MultiRspackOptions;
}
case '@affine/ios':
case '@affine/android': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig,
{ includeMermaidAndTypst: false }
);
workerConfigs.push(
createRspackWorkerTargetConfig(
pkg,
pkg.srcPath.join('nbstore.worker.ts').value
)
);
return [
createRspackHTMLTargetConfig(
pkg,
pkg.srcPath.join('index.tsx').value,
{},
workerConfigs.map(config => config.name)
),
...workerConfigs,
] as MultiRspackOptions;
}
case '@affine/electron-renderer': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig
createRspackWorkerTargetConfig,
{ includeMermaidAndTypst: false }
);
return [
+18
View File
@@ -90,6 +90,22 @@ export function createHTMLTargetConfig(
);
const buildConfig = getBuildConfigFromEnv(pkg);
const codeBlockPreviewBackendFile =
buildConfig.distribution === 'desktop'
? 'platform-backend.desktop.ts'
: buildConfig.distribution === 'ios' ||
buildConfig.distribution === 'android'
? 'platform-backend.mobile.ts'
: 'platform-backend.ts';
const codeBlockPreviewBackendAlias = ProjectRoot.join(
'packages',
'frontend',
'core',
'src',
'modules',
'code-block-preview-renderer',
codeBlockPreviewBackendFile
).value;
console.log(
`Building [${pkg.name}] for [${buildConfig.appBuildType}] channel in [${buildConfig.debug ? 'development' : 'production'}] mode.`
@@ -145,6 +161,8 @@ export function createHTMLTargetConfig(
'@preact',
'signals-core'
).value,
'@affine/core/modules/code-block-preview-renderer/platform-backend':
codeBlockPreviewBackendAlias,
},
},
//#endregion
+151 -173
View File
@@ -431,6 +431,7 @@ __metadata:
"@testing-library/dom": "npm:^10.4.0"
"@testing-library/react": "npm:^16.1.0"
"@toeverything/infra": "workspace:*"
"@toeverything/mermaid-wasm": "npm:^0.1.0"
"@toeverything/pdf-viewer": "npm:^0.1.1"
"@toeverything/theme": "npm:^1.1.23"
"@types/animejs": "npm:^3.1.12"
@@ -447,6 +448,7 @@ __metadata:
cmdk: "npm:^1.0.4"
core-js: "npm:^3.39.0"
dayjs: "npm:^1.11.13"
dompurify: "npm:^3.3.0"
eventemitter2: "npm:^6.4.9"
fake-indexeddb: "npm:^6.0.0"
file-type: "npm:^21.0.0"
@@ -468,7 +470,7 @@ __metadata:
lit: "npm:^3.2.1"
lodash-es: "npm:^4.17.23"
lottie-react: "npm:^2.4.0"
mermaid: "npm:^11.12.2"
mermaid: "npm:^11.13.0"
mp4-muxer: "npm:^5.2.2"
nanoid: "npm:^5.1.6"
next-themes: "npm:^0.4.4"
@@ -1136,13 +1138,6 @@ __metadata:
languageName: node
linkType: hard
"@antfu/utils@npm:^9.2.0":
version: 9.2.1
resolution: "@antfu/utils@npm:9.2.1"
checksum: 10/c629769f5301d16851de18e241cc0c7547928b080929bcfd945735eb799d13029336236bfe869935e832f3e48f8b622caf5946738158b4dd4133701f19a9b75a
languageName: node
linkType: hard
"@apollo/cache-control-types@npm:^1.0.3":
version: 1.0.3
resolution: "@apollo/cache-control-types@npm:1.0.3"
@@ -1736,6 +1731,7 @@ __metadata:
"@toeverything/theme": "npm:^1.1.23"
"@vitest/browser-playwright": "npm:^4.0.18"
lit: "npm:^3.2.0"
playwright: "npm:=1.58.2"
rxjs: "npm:^7.8.2"
vitest: "npm:^4.0.18"
yjs: "npm:^13.6.27"
@@ -2800,6 +2796,7 @@ __metadata:
lit: "npm:^3.2.0"
lit-html: "npm:^3.2.1"
lodash-es: "npm:^4.17.23"
playwright: "npm:=1.58.2"
rxjs: "npm:^7.8.2"
vitest: "npm:^4.0.18"
yjs: "npm:^13.6.27"
@@ -3610,6 +3607,7 @@ __metadata:
"@vanilla-extract/vite-plugin": "npm:^5.0.0"
"@vitest/browser-playwright": "npm:^4.0.18"
lit: "npm:^3.2.0"
playwright: "npm:=1.58.2"
rxjs: "npm:^7.8.2"
vite: "npm:^7.2.7"
vite-plugin-istanbul: "npm:^7.2.1"
@@ -3680,6 +3678,7 @@ __metadata:
lit: "npm:^3.2.0"
lodash-es: "npm:^4.17.23"
lz-string: "npm:^1.5.0"
playwright: "npm:=1.58.2"
rehype-parse: "npm:^9.0.0"
rxjs: "npm:^7.8.2"
unified: "npm:^11.0.5"
@@ -3740,9 +3739,9 @@ __metadata:
linkType: hard
"@braintree/sanitize-url@npm:^7.1.1":
version: 7.1.1
resolution: "@braintree/sanitize-url@npm:7.1.1"
checksum: 10/a8a5535c5a0a459ba593a018c554b35493dff004fd09d7147db67243df83bce3d410b89ee7dc2d95cce195b85b877c72f8ca149e1040110a945d193c67293af0
version: 7.1.2
resolution: "@braintree/sanitize-url@npm:7.1.2"
checksum: 10/d9626ff8f8eb5e192cd055e6e743449c21102c76bb59e405b7028fe56230fa080bfcc80dfb1e21850a6876e75adda9f7b3c888cf0685942bb74da4d2866d6ec3
languageName: node
linkType: hard
@@ -3855,45 +3854,45 @@ __metadata:
languageName: node
linkType: hard
"@chevrotain/cst-dts-gen@npm:11.0.3":
version: 11.0.3
resolution: "@chevrotain/cst-dts-gen@npm:11.0.3"
"@chevrotain/cst-dts-gen@npm:11.1.2":
version: 11.1.2
resolution: "@chevrotain/cst-dts-gen@npm:11.1.2"
dependencies:
"@chevrotain/gast": "npm:11.0.3"
"@chevrotain/types": "npm:11.0.3"
lodash-es: "npm:4.17.21"
checksum: 10/601d23fa3312bd0e32816bd3f9ca2dcba775a52192a082fd6c5e4a2e8ee068523401191babbe2c346d6d2551900a67b549f2f74d7ebb7d5b2ee1b6fa3c8857a0
"@chevrotain/gast": "npm:11.1.2"
"@chevrotain/types": "npm:11.1.2"
lodash-es: "npm:4.17.23"
checksum: 10/04a84285fd4d26129f44f8e20b99fd6ab51cb1ee4c7eb104baa4842413b9e18bb171046a665583b94ff29e2c06a2e1a65205940a75cda848aa2a5e847b99bbb6
languageName: node
linkType: hard
"@chevrotain/gast@npm:11.0.3":
version: 11.0.3
resolution: "@chevrotain/gast@npm:11.0.3"
"@chevrotain/gast@npm:11.1.2":
version: 11.1.2
resolution: "@chevrotain/gast@npm:11.1.2"
dependencies:
"@chevrotain/types": "npm:11.0.3"
lodash-es: "npm:4.17.21"
checksum: 10/7169453a8fbfa994e91995523dea09eab87ab23062ad93f6e51f4a3b03f5e2958e0a8b99d5ca6fa067fccfbbbb8bcf1a4573ace2e1b5a455f6956af9eaccb35a
"@chevrotain/types": "npm:11.1.2"
lodash-es: "npm:4.17.23"
checksum: 10/8733cbadfcb982f5afe0e1825446ad8e82de3044c9ae22a69771ec20292facce25e459da23c16061c894ef49ca381ad53c0796171fa0dfce08fc25305850eeca
languageName: node
linkType: hard
"@chevrotain/regexp-to-ast@npm:11.0.3":
version: 11.0.3
resolution: "@chevrotain/regexp-to-ast@npm:11.0.3"
checksum: 10/7387a1c61c5a052de41e1172b33eaaedea166fcdb1ffe4c381b86d00051a8014855a031d28fb658768a62c833ef5f5b0689d0c40de3d7bed556f8fea24396e69
"@chevrotain/regexp-to-ast@npm:11.1.2":
version: 11.1.2
resolution: "@chevrotain/regexp-to-ast@npm:11.1.2"
checksum: 10/9ba399b2c23ae1a86f1bcb1db4b07fd3191d0f491b63303b824850ef5f8b455f8f8a39c4d7876271c23ca06f7e03b7273b07014cd9c8ccb2328bff5dc6c9df00
languageName: node
linkType: hard
"@chevrotain/types@npm:11.0.3":
version: 11.0.3
resolution: "@chevrotain/types@npm:11.0.3"
checksum: 10/49a82b71d2de8ceb2383ff2709fa61d245f2ab2e42790b70c57102c80846edaa318d0b3645aedc904d23ea7bd9be8a58f2397b1341760a15eb5aa95a1336e2a9
"@chevrotain/types@npm:11.1.2":
version: 11.1.2
resolution: "@chevrotain/types@npm:11.1.2"
checksum: 10/ad39d7651a20b05ead9b4374f98afc39915118dda29678c2b623571aa3da3018a0f3fb799d1809605cafcae59cba637d640855ea0f97b9b01d0900d77d6781fe
languageName: node
linkType: hard
"@chevrotain/utils@npm:11.0.3":
version: 11.0.3
resolution: "@chevrotain/utils@npm:11.0.3"
checksum: 10/29b5d84373a7761ad055c53e2f540a67b5b56550d5be1c473149f6b8923eef87ff391ce021c06ac7653843b0149f6ff0cf30b5e48c3f825d295eb06a6c517bd3
"@chevrotain/utils@npm:11.1.2":
version: 11.1.2
resolution: "@chevrotain/utils@npm:11.1.2"
checksum: 10/a5ba5886547665726e6680bdc6e1c939e643c8c606d8eb3806ed178ebb2c3a8a1a581db558412374f230babfe9d8c1c37f2c3edf260c63729dbe6ff9ca4f0223
languageName: node
linkType: hard
@@ -6391,19 +6390,14 @@ __metadata:
languageName: node
linkType: hard
"@iconify/utils@npm:^3.0.1":
version: 3.0.2
resolution: "@iconify/utils@npm:3.0.2"
"@iconify/utils@npm:^3.0.2":
version: 3.1.0
resolution: "@iconify/utils@npm:3.1.0"
dependencies:
"@antfu/install-pkg": "npm:^1.1.0"
"@antfu/utils": "npm:^9.2.0"
"@iconify/types": "npm:^2.0.0"
debug: "npm:^4.4.1"
globals: "npm:^15.15.0"
kolorist: "npm:^1.8.0"
local-pkg: "npm:^1.1.1"
mlly: "npm:^1.7.4"
checksum: 10/b2db57b6a6b06d618b2625bf7bd056219a6c65e71a86c79c664d5e5fe03e531bc74fdd9cfa4d74e2ea469b6cd92b63012e2d588b32cade5aa7e3c31bc5124789
mlly: "npm:^1.8.0"
checksum: 10/28e83311ec7eca3f94a9c128c6d6f0f6aa68b7a63bcac44d08a1ea6f94d3752a7447a4354f3d02fdcdbf782ba033784ef7a65212b3afe52d9b41ef8138e96b14
languageName: node
linkType: hard
@@ -7531,12 +7525,12 @@ __metadata:
languageName: node
linkType: hard
"@mermaid-js/parser@npm:^0.6.3":
version: 0.6.3
resolution: "@mermaid-js/parser@npm:0.6.3"
"@mermaid-js/parser@npm:^1.0.1":
version: 1.0.1
resolution: "@mermaid-js/parser@npm:1.0.1"
dependencies:
langium: "npm:3.3.1"
checksum: 10/ab8bbdeaf2ef556871f3267541c0b3621d70c4d108ddac36383adc7eb1c7e6bed28d068b4ad196b54314877f263f939f90f0a1a3cfe8576fab30f4514732aa2f
langium: "npm:^4.0.0"
checksum: 10/648c96da8464113c3694587f3f950421b1914d34fddc194b6dff7a8c28da5e37273ddc4d56a4efaeda82e93c7a168389f035dbac6c8d5b65930ee60b5063ece4
languageName: node
linkType: hard
@@ -15772,6 +15766,13 @@ __metadata:
languageName: unknown
linkType: soft
"@toeverything/mermaid-wasm@npm:^0.1.0":
version: 0.1.1
resolution: "@toeverything/mermaid-wasm@npm:0.1.1"
checksum: 10/4b701f19aad5fbe42ddec9d63c6e3cf7641ff1ab9277142db7adcde3e9b81315e2d35cfab82acead79bb81d560eef10d1d7522ab17cca300e6dae45db0380eab
languageName: node
linkType: hard
"@toeverything/pdf-viewer-types@npm:0.1.1":
version: 0.1.1
resolution: "@toeverything/pdf-viewer-types@npm:0.1.1"
@@ -17488,6 +17489,21 @@ __metadata:
languageName: node
linkType: hard
"@upsetjs/venn.js@npm:^2.0.0":
version: 2.0.0
resolution: "@upsetjs/venn.js@npm:2.0.0"
dependencies:
d3-selection: "npm:^3.0.0"
d3-transition: "npm:^3.0.1"
dependenciesMeta:
d3-selection:
optional: true
d3-transition:
optional: true
checksum: 10/84505be440490666566a6f59e765e1f24b154e4ca45cfed9102f02261be45912149f706e21b3cc7a6247816a8703e1f1b667561295b587c6dcc3162a9a48a5f2
languageName: node
linkType: hard
"@vanilla-extract/babel-plugin-debug-ids@npm:^1.2.2":
version: 1.2.2
resolution: "@vanilla-extract/babel-plugin-debug-ids@npm:1.2.2"
@@ -18149,7 +18165,7 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.6.0, acorn@npm:^8.8.2":
"acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.6.0, acorn@npm:^8.8.2":
version: 8.16.0
resolution: "acorn@npm:8.16.0"
bin:
@@ -19740,7 +19756,7 @@ __metadata:
languageName: node
linkType: hard
"chevrotain-allstar@npm:~0.3.0":
"chevrotain-allstar@npm:~0.3.1":
version: 0.3.1
resolution: "chevrotain-allstar@npm:0.3.1"
dependencies:
@@ -19751,17 +19767,17 @@ __metadata:
languageName: node
linkType: hard
"chevrotain@npm:~11.0.3":
version: 11.0.3
resolution: "chevrotain@npm:11.0.3"
"chevrotain@npm:~11.1.1":
version: 11.1.2
resolution: "chevrotain@npm:11.1.2"
dependencies:
"@chevrotain/cst-dts-gen": "npm:11.0.3"
"@chevrotain/gast": "npm:11.0.3"
"@chevrotain/regexp-to-ast": "npm:11.0.3"
"@chevrotain/types": "npm:11.0.3"
"@chevrotain/utils": "npm:11.0.3"
lodash-es: "npm:4.17.21"
checksum: 10/8fa6253e51320dd4c3d386315b925734943e509d7954a2cd917746c0604461191bea57b0fb8fbab1903e0508fd94bfd35ebd0f8eace77cd0f3f42a9ee4f8f676
"@chevrotain/cst-dts-gen": "npm:11.1.2"
"@chevrotain/gast": "npm:11.1.2"
"@chevrotain/regexp-to-ast": "npm:11.1.2"
"@chevrotain/types": "npm:11.1.2"
"@chevrotain/utils": "npm:11.1.2"
lodash-es: "npm:4.17.23"
checksum: 10/67caa47a3d38eeb4a584960bbf5fdc83894a1419a6428f7dcf8a07f5937c1bda4c8e1acf628fe80d3be8f4c1a84426942c0a8f6bcf44a5958f1df0cf4225bc4d
languageName: node
linkType: hard
@@ -21051,7 +21067,7 @@ __metadata:
languageName: node
linkType: hard
"cytoscape@npm:^3.29.3":
"cytoscape@npm:^3.33.1":
version: 3.33.1
resolution: "cytoscape@npm:3.33.1"
checksum: 10/0e8d3ea87eb624899341d6a765cfb732199af8a871beedeb94971061632ce814c2c39e8257d6628c5611ca9dadc1a723a00377d04f149e0d24f6c133a6ab8647
@@ -21196,9 +21212,9 @@ __metadata:
linkType: hard
"d3-format@npm:1 - 3, d3-format@npm:3":
version: 3.1.0
resolution: "d3-format@npm:3.1.0"
checksum: 10/a0fe23d2575f738027a3db0ce57160e5a473ccf24808c1ed46d45ef4f3211076b34a18b585547d34e365e78dcc26dd4ab15c069731fc4b1c07a26bfced09ea31
version: 3.1.2
resolution: "d3-format@npm:3.1.2"
checksum: 10/811d913c2c7624cb0d2a8f0ccd7964c50945b3de3c7f7aa14c309fba7266a3ec53cbee8c05f6ad61b2b65b93e157c55a0e07db59bc3180c39dac52be8e841ab1
languageName: node
linkType: hard
@@ -21295,7 +21311,7 @@ __metadata:
languageName: node
linkType: hard
"d3-selection@npm:2 - 3, d3-selection@npm:3":
"d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0":
version: 3.0.0
resolution: "d3-selection@npm:3.0.0"
checksum: 10/0e5acfd305b31628b7be5009ba7303d84bb34817a88ed4dde9c8bd9c23528573fc5272f89fc04e5be03d2cbf5441a248d7274aaf55a8ef3dad46e16333d72298
@@ -21345,7 +21361,7 @@ __metadata:
languageName: node
linkType: hard
"d3-transition@npm:2 - 3, d3-transition@npm:3":
"d3-transition@npm:2 - 3, d3-transition@npm:3, d3-transition@npm:^3.0.1":
version: 3.0.1
resolution: "d3-transition@npm:3.0.1"
dependencies:
@@ -21411,13 +21427,13 @@ __metadata:
languageName: node
linkType: hard
"dagre-d3-es@npm:7.0.13":
version: 7.0.13
resolution: "dagre-d3-es@npm:7.0.13"
"dagre-d3-es@npm:7.0.14":
version: 7.0.14
resolution: "dagre-d3-es@npm:7.0.14"
dependencies:
d3: "npm:^7.9.0"
lodash-es: "npm:^4.17.21"
checksum: 10/f6dbd373b85cc9fbcb23fba996656a0336ba48bc46f1e6d31c582418a5086caf230a4e8178b90acd7b1d14b090cbba2db50dc64484d67cf9c8856a4a2fe30cf0
checksum: 10/f2787049ae2684de27950dfc61eb23437cbb5c3ca7ec7f58620e19f16059465b6d23324ca861961353a60bb4cdaa5c66edfa9bbe44ac2304b72dd00ab4199714
languageName: node
linkType: hard
@@ -21474,10 +21490,10 @@ __metadata:
languageName: node
linkType: hard
"dayjs@npm:^1.11.13, dayjs@npm:^1.11.18":
version: 1.11.18
resolution: "dayjs@npm:1.11.18"
checksum: 10/7d29a90834cf4da2feb437c2f34b8235c3f94493a06d2f1bf9f506f1fa49eadf796f26e1d685b9fe8cb5e75ce6ee067825115e196f1af3d07b3552ff857bfc39
"dayjs@npm:^1.11.13, dayjs@npm:^1.11.19":
version: 1.11.20
resolution: "dayjs@npm:1.11.20"
checksum: 10/5347533f21a55b8bb1b1ef559be9b805514c3a8fb7e68b75fb7e73808131c59e70909c073aa44ce8a0d159195cd110cdd4081cf87ab96cb06fee3edacae791c6
languageName: node
linkType: hard
@@ -21945,15 +21961,15 @@ __metadata:
languageName: node
linkType: hard
"dompurify@npm:^3.2.5, dompurify@npm:^3.3.0":
version: 3.3.2
resolution: "dompurify@npm:3.3.2"
"dompurify@npm:^3.3.0, dompurify@npm:^3.3.1":
version: 3.3.3
resolution: "dompurify@npm:3.3.3"
dependencies:
"@types/trusted-types": "npm:^2.0.7"
dependenciesMeta:
"@types/trusted-types":
optional: true
checksum: 10/3ca02559677ce6d9583a500f21ffbb6b9e88f1af99f69fa0d0d9442cddbac98810588c869f8b435addb5115492d6e49870024bca322169b941bafedb99c7f281
checksum: 10/4cc9c539ed7136d46c6577613b8e20871c2b6165db01dfbd2a3c11c75f9e339c496ac6519a1c3190115def8cadae3720bef0417fc43fa28802c7407bab174da9
languageName: node
linkType: hard
@@ -24462,13 +24478,6 @@ __metadata:
languageName: node
linkType: hard
"globals@npm:^15.15.0":
version: 15.15.0
resolution: "globals@npm:15.15.0"
checksum: 10/7f561c87b2fd381b27fc2db7df8a4ea7a9bb378667b8a7193e61fd2ca3a876479174e2a303a74345fbea6e1242e16db48915c1fd3bf35adcf4060a795b425e18
languageName: node
linkType: hard
"globals@npm:^16.4.0":
version: 16.5.0
resolution: "globals@npm:16.5.0"
@@ -26663,14 +26672,14 @@ __metadata:
languageName: node
linkType: hard
"katex@npm:^0.16.0, katex@npm:^0.16.22, katex@npm:^0.16.27":
version: 0.16.27
resolution: "katex@npm:0.16.27"
"katex@npm:^0.16.0, katex@npm:^0.16.25, katex@npm:^0.16.27":
version: 0.16.38
resolution: "katex@npm:0.16.38"
dependencies:
commander: "npm:^8.3.0"
bin:
katex: cli.js
checksum: 10/7666ae11c6c1238626bffaf1a526af6ff679114d62293bf2f0e29f8a34d8e961c0edcb686c5b628158ec92a143b4bef5d83539c81b29a63c7dcf0bdb4544eec9
checksum: 10/e9103def114d9d08ab216864e66b68e6f50a6360fdc5aa29d8edeee430e1618dd7551b9f080e9c591b3ee24c18fe6910b8fe0c89c7c4b1109abd2b63e223fbc5
languageName: node
linkType: hard
@@ -26704,13 +26713,6 @@ __metadata:
languageName: node
linkType: hard
"kolorist@npm:^1.8.0":
version: 1.8.0
resolution: "kolorist@npm:1.8.0"
checksum: 10/71d5d122951cc65f2f14c3e1d7f8fd91694b374647d4f6deec3816d018cd04a44edd9578d93e00c82c2053b925e5d30a0565746c4171f4ca9fce1a13bd5f3315
languageName: node
linkType: hard
"kuler@npm:^2.0.0":
version: 2.0.0
resolution: "kuler@npm:2.0.0"
@@ -26718,16 +26720,16 @@ __metadata:
languageName: node
linkType: hard
"langium@npm:3.3.1":
version: 3.3.1
resolution: "langium@npm:3.3.1"
"langium@npm:^4.0.0":
version: 4.2.1
resolution: "langium@npm:4.2.1"
dependencies:
chevrotain: "npm:~11.0.3"
chevrotain-allstar: "npm:~0.3.0"
chevrotain: "npm:~11.1.1"
chevrotain-allstar: "npm:~0.3.1"
vscode-languageserver: "npm:~9.0.1"
vscode-languageserver-textdocument: "npm:~1.0.11"
vscode-uri: "npm:~3.0.8"
checksum: 10/6b2e5bc1ff47c6048ec24471333f3397ddb4d6419f1c2262268faff00a8f0839ac4bd4877907261273e91e82f239951249155c3aff8d395ee5e3372dfc285e04
vscode-uri: "npm:~3.1.0"
checksum: 10/9fd9208762ae9b551ec1deea25b6633605b06b59505a1ce6bf5b6c639779378bab35f813ffe549db57af95a5b71f9dcf61e553273a5d9d6ad2a3a99c5b7bfcc2
languageName: node
linkType: hard
@@ -27105,17 +27107,6 @@ __metadata:
languageName: node
linkType: hard
"local-pkg@npm:^1.1.1":
version: 1.1.2
resolution: "local-pkg@npm:1.1.2"
dependencies:
mlly: "npm:^1.7.4"
pkg-types: "npm:^2.3.0"
quansync: "npm:^0.2.11"
checksum: 10/761d82f40849e4721fa50d86782cf75bc2befb0696f32ac99869fb6f3033b904e4018f4bb8cdfde994d710816480dc1aba8e462c67ec20fe89d4700a245d17f8
languageName: node
linkType: hard
"locate-path@npm:^2.0.0":
version: 2.0.0
resolution: "locate-path@npm:2.0.0"
@@ -27153,14 +27144,7 @@ __metadata:
languageName: node
linkType: hard
"lodash-es@npm:4.17.21":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 10/03f39878ea1e42b3199bd3f478150ab723f93cc8730ad86fec1f2804f4a07c6e30deaac73cad53a88e9c3db33348bb8ceeb274552390e7a75d7849021c02df43
languageName: node
linkType: hard
"lodash-es@npm:^4.17.21, lodash-es@npm:^4.17.23":
"lodash-es@npm:4.17.23, lodash-es@npm:^4.17.21, lodash-es@npm:^4.17.23":
version: 4.17.23
resolution: "lodash-es@npm:4.17.23"
checksum: 10/1feae200df22eb0bd93ca86d485e77784b8a9fb1d13e91b66e9baa7a7e5e04be088c12a7e20c2250fc0bd3db1bc0ef0affc7d9e3810b6af2455a3c6bf6dde59e
@@ -27762,12 +27746,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:^16.2.1":
version: 16.3.0
resolution: "marked@npm:16.3.0"
"marked@npm:^16.3.0":
version: 16.4.2
resolution: "marked@npm:16.4.2"
bin:
marked: bin/marked.js
checksum: 10/60497834b9acfb3b3994222509d359ecb9a197c885dfeb77e2050a287cd2f4ab19f00d5597172b47f9e0c54d9e1e13d8b2dd73322b7838599e1f16d1d6283f5b
checksum: 10/6e40e40661dce97e271198daa2054fc31e6445892a735e416c248fba046bdfa4573cafa08dc254529f105e7178a34485eb7f82573979cfb377a4530f66e79187
languageName: node
linkType: hard
@@ -28119,31 +28103,32 @@ __metadata:
languageName: node
linkType: hard
"mermaid@npm:^11.12.2":
version: 11.12.2
resolution: "mermaid@npm:11.12.2"
"mermaid@npm:^11.13.0":
version: 11.13.0
resolution: "mermaid@npm:11.13.0"
dependencies:
"@braintree/sanitize-url": "npm:^7.1.1"
"@iconify/utils": "npm:^3.0.1"
"@mermaid-js/parser": "npm:^0.6.3"
"@iconify/utils": "npm:^3.0.2"
"@mermaid-js/parser": "npm:^1.0.1"
"@types/d3": "npm:^7.4.3"
cytoscape: "npm:^3.29.3"
"@upsetjs/venn.js": "npm:^2.0.0"
cytoscape: "npm:^3.33.1"
cytoscape-cose-bilkent: "npm:^4.1.0"
cytoscape-fcose: "npm:^2.2.0"
d3: "npm:^7.9.0"
d3-sankey: "npm:^0.12.3"
dagre-d3-es: "npm:7.0.13"
dayjs: "npm:^1.11.18"
dompurify: "npm:^3.2.5"
katex: "npm:^0.16.22"
dagre-d3-es: "npm:7.0.14"
dayjs: "npm:^1.11.19"
dompurify: "npm:^3.3.1"
katex: "npm:^0.16.25"
khroma: "npm:^2.1.0"
lodash-es: "npm:^4.17.21"
marked: "npm:^16.2.1"
lodash-es: "npm:^4.17.23"
marked: "npm:^16.3.0"
roughjs: "npm:^4.6.6"
stylis: "npm:^4.3.6"
ts-dedent: "npm:^2.2.0"
uuid: "npm:^11.1.0"
checksum: 10/3c07c1be97a830904c7802933664abd132d626921c3aa82db8d0fbaad35832907cbaa2250747f17e110de5d6f4bdd1fcb9f0416b42c8e59a73653e809333d3da
checksum: 10/7bf2123b005925983eb4d528896665963002c3a617f13fe9d356e4910ff71d3ad0c3f817e717414828f9cdba147651f2ac28af15c60c085f4444f5a786691dd1
languageName: node
linkType: hard
@@ -28801,15 +28786,15 @@ __metadata:
languageName: node
linkType: hard
"mlly@npm:^1.4.2, mlly@npm:^1.7.1, mlly@npm:^1.7.4":
version: 1.7.4
resolution: "mlly@npm:1.7.4"
"mlly@npm:^1.4.2, mlly@npm:^1.7.1, mlly@npm:^1.7.4, mlly@npm:^1.8.0":
version: 1.8.1
resolution: "mlly@npm:1.8.1"
dependencies:
acorn: "npm:^8.14.0"
pathe: "npm:^2.0.1"
pkg-types: "npm:^1.3.0"
ufo: "npm:^1.5.4"
checksum: 10/1b36163d38c2331f8ae480e6a11da3d15927a2148d729fcd9df6d0059ca74869aa693931bd1f762f82eb534b84c921bdfbc036eb0e4da4faeb55f1349d254f35
acorn: "npm:^8.16.0"
pathe: "npm:^2.0.3"
pkg-types: "npm:^1.3.1"
ufo: "npm:^1.6.3"
checksum: 10/8e424f0615d09adfb7d59ad8f0c8245df275cd05e483a4631a1b2c5dd7e09913a9ce8182bc1562d569941ecee25ab03f4429284265471f562da1dd308008e237
languageName: node
linkType: hard
@@ -30088,9 +30073,9 @@ __metadata:
linkType: hard
"package-manager-detector@npm:^1.3.0":
version: 1.3.0
resolution: "package-manager-detector@npm:1.3.0"
checksum: 10/b21155d53a8ab96d5be3bfae43cc1d397bf363782b922d1f6967d220d2a9f08234ebb76035318bf92822ce761d10451959f01019faebc08fdb4d4a8bc3103da6
version: 1.6.0
resolution: "package-manager-detector@npm:1.6.0"
checksum: 10/b38a9532198cefdb98a1b7131c42cbffa55d8b997d6117811cf83f00079fd57a572db2aa5e3db5e36bcd0af84d0bec5a7d6251142427314390ed99a3d76cd0a0
languageName: node
linkType: hard
@@ -30598,7 +30583,7 @@ __metadata:
languageName: node
linkType: hard
"pkg-types@npm:^1.2.0, pkg-types@npm:^1.3.0, pkg-types@npm:^1.3.1":
"pkg-types@npm:^1.2.0, pkg-types@npm:^1.3.1":
version: 1.3.1
resolution: "pkg-types@npm:1.3.1"
dependencies:
@@ -30609,7 +30594,7 @@ __metadata:
languageName: node
linkType: hard
"pkg-types@npm:^2.0.0, pkg-types@npm:^2.1.0, pkg-types@npm:^2.3.0":
"pkg-types@npm:^2.0.0, pkg-types@npm:^2.1.0":
version: 2.3.0
resolution: "pkg-types@npm:2.3.0"
dependencies:
@@ -31416,13 +31401,6 @@ __metadata:
languageName: node
linkType: hard
"quansync@npm:^0.2.11":
version: 0.2.11
resolution: "quansync@npm:0.2.11"
checksum: 10/d4f0cc21a25052a8a6183f17752a6221829c4795b40641de67c06945b356841ff00296d3700d0332dfe8e86100fdcc02f4be7559f3f1774a753b05adb7800d01
languageName: node
linkType: hard
"query-string@npm:^9.1.1":
version: 9.2.0
resolution: "query-string@npm:9.2.0"
@@ -34637,9 +34615,9 @@ __metadata:
linkType: hard
"tinyexec@npm:^1.0.0, tinyexec@npm:^1.0.1, tinyexec@npm:^1.0.2":
version: 1.0.2
resolution: "tinyexec@npm:1.0.2"
checksum: 10/cb709ed4240e873d3816e67f851d445f5676e0ae3a52931a60ff571d93d388da09108c8057b62351766133ee05ff3159dd56c3a0fbd39a5933c6639ce8771405
version: 1.0.4
resolution: "tinyexec@npm:1.0.4"
checksum: 10/ccebe4044eef6fa5050929df7862fda70b4fb700f15d94aef8ae6109b9d194dbc3a990125d99944fd25b90fe2115e1927f055b909a604c571a81b647ede5757a
languageName: node
linkType: hard
@@ -35169,10 +35147,10 @@ __metadata:
languageName: node
linkType: hard
"ufo@npm:^1.5.4":
version: 1.6.1
resolution: "ufo@npm:1.6.1"
checksum: 10/088a68133b93af183b093e5a8730a40fe7fd675d3dc0656ea7512f180af45c92300c294f14d4d46d4b2b553e3e52d3b13d4856b9885e620e7001edf85531234e
"ufo@npm:^1.5.4, ufo@npm:^1.6.3":
version: 1.6.3
resolution: "ufo@npm:1.6.3"
checksum: 10/79803984f3e414567273a666183d6a50d1bec0d852100a98f55c1e393cb705e3b88033e04029dd651714e6eec99e1b00f54fdc13f32404968251a16f8898cfe5
languageName: node
linkType: hard
@@ -36051,10 +36029,10 @@ __metadata:
languageName: node
linkType: hard
"vscode-uri@npm:~3.0.8":
version: 3.0.8
resolution: "vscode-uri@npm:3.0.8"
checksum: 10/e882d6b679e0d053cbc042893c0951a135d899a192b62cd07f0a8924f11ae722067a8d6b1b5b147034becf57faf9fff9fb543b17b749fd0f17db1f54f783f07c
"vscode-uri@npm:~3.1.0":
version: 3.1.0
resolution: "vscode-uri@npm:3.1.0"
checksum: 10/80c2a2421f44b64008ef1f91dfa52a2d68105cbb4dcea197dbf5b00c65ccaccf218b615e93ec587f26fc3ba04796898f3631a9406e3b04cda970c3ca8eadf646
languageName: node
linkType: hard