Importers: what a dropped file becomes
Dropping, pasting or picking a file runs it through the importer registry. A feature contributes importers: Importer[]; the editor asks them, in feature order, which one takes the file. The result is an ImportPlan: the blocks to insert, plus facts about the file as a whole. fileImport (@skaile/forge-editor/media) inserts the blocks at the drop position and shows the plan's warnings as one notice.
import type { Importer } from '@skaile/forge-editor/core'
const csvImporter: Importer = {
id: 'csv',
accepts: ['.csv', 'text/csv'],
async plan(editor, file, text) {
// `text` is the file's text for a text file of at most MAX_TEXT_BYTES, else null
return { blocks: [{ type: 'paragraph', content: [{ type: 'text', text: text ?? '' }] }] }
},
}
export const csvFeature = (): EditorFeature<never> => ({ id: 'csv', dimension: 'content', importers: [csvImporter] })
Which importer takes a file
accepts lists MIME types (text/markdown, audio/*), extensions with their dot (.vtt, not case-sensitive) or *.
- Specific entries. The first importer in feature order with a matching MIME type or extension plans the file. A file without a match goes to the fallback: text and Markdown files are parsed through the block pipeline (parse, normalize, segment, structure), everything else is uploaded as a media or file block.
- Wrapping importers (
*). An importer that accepts*takes every file but plans last. It receives the plan made so far asbaseand returns the final plan.composite-mediais one: it puts the blocks of every file into one Typed block. - Text. An importer that matched by extension or a
text/*type gets the file's text. Files aboveMAX_TEXT_BYTES(2 MB) are not read; such a file goes to the fallback.
Importer ids must be unique: defineEditor throws for a second importers entry with the same id. Importers are additive, so two features may both contribute some. planImport(editor, file, fallback) is the framework-free entry; importersOf(features) lists the contributions.
The importers of the media feature
The media feature contributes three importers (FILE_IMPORTERS, in this order).
| Importer | Takes | Plan |
|---|---|---|
markdown | .md, .markdown, text/markdown | The body parsed through the block pipeline. The frontmatter keys become properties |
subtitle | .vtt, .srt, text/vtt | One Typed block of Block type rendition, titled with the file name, holding one transcriptSegment per cue (start, end in seconds, speaker from a WebVTT <v Name> tag) |
bibtex | .bib, application/x-bibtex | One Typed block per entry: paper, or book for CSL book. The entry is the CSL record in meta.bib (doi / isbn too) |
Without the composites feature there are no Typed blocks: the subtitle importer then inserts the segments as they are (paragraphs Sprecher: Text without the transcript feature), the BibTeX importer one citation paragraph per entry.
Frontmatter is read by parseFrontmatter with a small subset: scalars (quoted strings, numbers, true, false, null), flow lists (tags: [a, "b, c"]) and block lists (- item lines). Nested maps and multi-line scalars are skipped with a warning; the body is never affected. properties are not inserted by themselves: a wrapping importer merges them into the meta of the Typed block it creates, after which the file's own import facts (filename, mimeType, size, importedAt) are set and never replaced.
Subtitles (parseSubtitles): cue blocks are separated by blank lines; identifier lines (the numbers of SubRip), NOTE, STYLE and REGION blocks and cue settings are ignored, inline tags are dropped, cue lines are joined by a space. Times are hh:mm:ss.mmm, mm:ss.mmm or hh:mm:ss,mmm (cueTime). A block without a valid time range is skipped and reported.
BibTeX (parseBibtex): @type{key, field = {value} | "value" | number | macro # …}. author and editor split on and outside braces (Family, Given, Given Family or a braced institution), year and month (number or English name) or date become issued, @string macros are resolved, @comment and @preamble are skipped. LaTeX accent commands are not translated. An entry without key or title is skipped with a warning. The block's title is the entry's title; the CSL record carries no title.
composite-media
compositeMediaFeature() contributes the wrapping importer. For every planned file it creates one Typed block of the Block type whose import capability takes the file's media, titled with the file name without its extension. The plan's blocks become its body, the plan's properties and the file's facts its meta. Without a Block type that takes the file, the plan is inserted as it is. So it is when the plan is already Typed blocks and carries no properties: a dropped .vtt stays the one transcript block and a .bib its paper and book blocks, none of them wrapped in a document block. Markdown files (frontmatter properties) and plain media or text files keep one wrapper per file.
The wrapper needs a plan to wrap; it never reads files itself. It replaces the earlier wrapImport pipeline hook, which allowed one contributor: any number of features can now contribute importers, and wrapImport no longer exists.
Duplicates
Before a dropped file is inserted, the Block index is asked whether the workspace holds it already (backend.index.importCheck, route POST /api/editor/import/check, body { sha256?, doi?, title?, type? }, at least one of the first three). The answer is a list of ImportMatch (address, title, reason), at most ten, over the Documents the user may read:
| Reason | A Block matches when |
|---|---|
sha256 | its file has the same SHA-256 (properties.sha256: the hash of an uploaded media or file block, or of the first body block of a Typed block that wraps one). An authored sha256 property wins |
doi | its bibliographic record (properties.bib.DOI, else properties.doi) has the same DOI; case, doi: and the doi.org URL do not count |
title | its title has the same words in the same order (rankBlockRows ranks, titleMatchOf decides); with type, the same Block type too |
A Block is named once, by the first reason in that order. Of the Blocks of one Document that match for the same reason only the titled ones are kept: a Typed block that wraps an uploaded file and the image inside it carry the same hash, and the Typed block is the one to point at.
The file import (fileImport; useFileImport(), the media feature) asks in two steps. A file that is not text is hashed in the browser first (SHA-256 through WebCrypto), so a file the workspace holds is not uploaded a second time unless the user insists. Otherwise the file is planned and the check asks for every Typed block of the plan (at most 25) with its title, Block type and DOI (meta.bib.DOI, else meta.doi). A hit is not inserted. A notice offers two answers:
- Vorhandenen Block referenzieren inserts a reference chip to the existing Block instead. Of a plan only the duplicate blocks are replaced; a chip into the open Document names no Document.
- Trotzdem einfügen inserts the file or the whole plan as it would have been.
The notice stays 30 seconds; closing it skips the file. The drop position is anchored meanwhile, so the answer lands where the file was dropped. A check that fails, a missing index port (a backend without Block index) or an editor without reference chips is no duplicate: the file is inserted at once and nothing is asked. The memory backend answers from its own documents, the HTTP backend through the route. Notice.secondaryAction is the second button of the notice (Nuxt UI actions, Sonner cancel button).
Not included
Templates for new Documents are a separate guide (35.templates). Frontmatter outside the subset above is not imported.
Render: export a Block or a Document to a file
The Renderer port with its render targets, the SSE route that streams progress and stores the result as an asset, the client call, and the block action and command that offer it.
Templates: start a Document from a Dokumentvorlage
The Dokumentvorlagen folder, the placeholders expandTemplate fills in ({{title}}, {{date}}, {{time}}, {{cursor}}), and the templateId of the Document create route.