diff --git a/package.json b/package.json index a8d1276..3d0534d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "nostr-publish", "displayName": "Nostr Publish", "description": "Publish files to Nostr from Codium/VSCode. Phase 0: raw unsigned event JSON. Phase 1: long-form notes (.md). Phase 2: signer (Rust n_signer port) signing backend.", - "version": "0.1.2", + "version": "0.1.3", "publisher": "laantungir", "license": "MIT", "repository": { diff --git a/src/extension.ts b/src/extension.ts index cb9e7d7..3e3626b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1860,6 +1860,20 @@ function resolveBlossomServers(): string[] { return []; } +/** + * Append an explicit `charset=utf-8` parameter to `text/*` MIME types. + * + * Blossom servers store the upload `Content-Type` and echo it verbatim when + * serving the blob. Without a charset parameter, browsers fall back to + * Windows-1252 for text responses, rendering UTF-8 content as mojibake (an + * em dash `—` shows as `â€"`). Non-text types are returned unchanged. + */ +function withUtf8Charset(mime: string): string { + if (!mime.startsWith("text/")) return mime; + if (/;\s*charset=/i.test(mime)) return mime; + return `${mime}; charset=utf-8`; +} + /** * Shared upload + publish core for both the quick upload and header-based * publish commands. @@ -1870,11 +1884,15 @@ function resolveBlossomServers(): string[] { * 4. Builds a kind 1063 NIP-94 event from the first successful descriptor. * 5. Signs and publishes the 1063 event to the enabled relays. * - * Markdown bodies are uploaded as `text/plain` (browsers render text/plain - * inline; no mainstream browser displays text/markdown, and servers send - * `X-Content-Type-Options: nosniff` which forbids guessing). The kind 1063 - * event's `m` tag still advertises `text/markdown` so Nostr clients that - * render markdown can detect the file's true semantic type. + * Markdown bodies are uploaded as `text/plain; charset=utf-8` (browsers + * render text/plain inline; no mainstream browser displays text/markdown, + * and servers send `X-Content-Type-Options: nosniff` which forbids + * guessing). The explicit charset is required: Blossom servers echo the + * upload `Content-Type` verbatim when serving the blob, and without it + * browsers fall back to Windows-1252, rendering UTF-8 text as mojibake + * (e.g. an em dash `—` becomes `â€"`). The kind 1063 event's `m` tag still + * advertises the bare semantic type (`text/markdown`) so Nostr clients + * that render markdown can detect the file's true type. */ async function uploadAndPublishMetadata( state: StateService, @@ -1885,9 +1903,14 @@ async function uploadAndPublishMetadata( ): Promise { // Hybrid MIME handling: markdown uploads go out as text/plain for // browser-renderability; the semantic type is preserved for the m tag. + // Text bodies carry an explicit charset=utf-8 (see withUtf8Charset). const isMarkdown = mime === "text/markdown"; - const uploadMime = isMarkdown ? "text/plain" : mime; - const semanticMime = isMarkdown ? "text/markdown" : undefined; + const uploadMime = withUtf8Charset(isMarkdown ? "text/plain" : mime); + // The m tag must carry the bare semantic MIME (no charset parameter): + // markdown → text/markdown; other text types → the bare type, since the + // server-reported descriptor type echoes the charset-annotated upload + // Content-Type. Binary types pass through unchanged (no override needed). + const semanticMime = isMarkdown || uploadMime !== mime ? mime : undefined; // Require sign-in. let signer = getSigner(state, config); diff --git a/src/nostr/nip94.ts b/src/nostr/nip94.ts index fc13bec..e81342f 100644 --- a/src/nostr/nip94.ts +++ b/src/nostr/nip94.ts @@ -16,12 +16,16 @@ export interface FileMetadataInput { /** * Override for the `m` tag (the file's semantic MIME type). * - * Markdown blobs are uploaded as `text/plain` so browsers render them - * inline (no mainstream browser displays `text/markdown`, and - * `X-Content-Type-Options: nosniff` forbids guessing). The server then - * reports `type: text/plain` in the descriptor. This override keeps the - * Nostr `m` tag advertising the true semantic type (`text/markdown`) so - * clients that render markdown can still detect it. + * Markdown blobs are uploaded as `text/plain; charset=utf-8` so browsers + * render them inline (no mainstream browser displays `text/markdown`, and + * `X-Content-Type-Options: nosniff` forbids guessing). Text uploads carry + * an explicit charset because Blossom servers echo the upload + * `Content-Type` verbatim when serving blobs — without it browsers fall + * back to Windows-1252 and render UTF-8 text as mojibake. The server then + * reports the charset-annotated type (e.g. `text/plain; charset=utf-8`) in + * the descriptor. This override keeps the Nostr `m` tag advertising the + * bare semantic type (`text/markdown`, `text/plain`, …) so clients that + * render markdown can still detect it. */ mimeType?: string; }