- File type validation: enforced in both the OS dialog (accept) and after selection.
- Cross-browser upload progress: use XMLHttpRequest rather than fetch for upload progress. Works reliably in Chrome, Firefox, Safari, Edge.
- Multipart details:
- S3 requires parts of at least 5 MiB except the last; default partSize is 8 MiB.
- We send ETag from the UploadPart response header as-is (usually quoted) to your backend. Your Python code should pass those ETags straight to boto3 complete_multipart_upload.
- Retries with backoff on failed parts, plus overall abort on error.
- Errors:
- 403 SignatureDoesNotMatch or Expired URL: the code bubbles error. If you want automatic re-sign, you can wrap signPart inside the retry loop (already done).
- Cancel: AbortController cleanly cancels in-flight XHRs and we try to abort the multipart server-side.
- Accessibility: react-dropzone handles input focus/keyboard, and you can add aria labels to buttons.
- Bundle size:
- Only react-dropzone is added. No AWS SDK in the browser.
- All logic is small, tree-shakeable TypeScript.
- CORS: Ensure your S3 bucket CORS allows PUT, HEAD, and exposes ETag. Example:
- AllowedMethods: GET, PUT, POST, HEAD
- ExposeHeaders: ETag
- AllowedHeaders includes Content-Type and x-amz-* headers
- If your paths differ, just tweak createRestS3Signer.
Want chunk pause/resume and even richer UI?
- You can extend the uploader to track per-part progress and implement pause/resume within the same session by caching which parts succeeded and aborting in-flight XHRs, then resuming un-uploaded parts later. The structure above already separates parts and will support that with small changes.
- If you later decide you want a batteries-included UI and resumability across page reloads, Uppy’s @uppy/aws-s3 is the most feature-complete. For now, the above keeps your bundle lean and UX solid.
If you share your exact Python endpoint shapes, I can tailor the signer adapter and add a thin MD5 checksum path if your policy requires it.
Mark Florkowski
Team
This also appears to be bad formatting from the model, it “forgot” to include codeblcok fences in it’s output.
Mark Florkowski
Team
Can you click the “Copy response” button and paste the output here?
this looks like bad output from the model, not a failure in the renderer.
Amethyst Meteor
That makes sense, I haven’t ran into this issue with other models. If it’s related to the model then feel free to close my ticket.
Here’s a full response example, you can ignore most of it. The parts where it didn’t put it in markdown when it usually would render in markdown are the sections below “Option A — Build a CLI import JSON“ and “Linking featured images and categories”:
Great question. Here’s a practical way to convert your WordPress posts JSON into data Contentful can ingest, with two supported paths:
Option A: Convert to the “space export” JSON shape for the Contentful CLI (contentful space import)
Option B: Map each WP post to a CMA REST payload and create/publish programmatically
I’ll show both, plus a TypeScript transformer you can run.
High-level mapping from your WP post to Contentful
Choose or create a Contentful content type, e.g., blogPost, with fields:
title (Symbol)
slug (Symbol, unique)
body (Rich Text or Long text)
excerpt (Long text)
featuredImage (Link → Asset) [optional]
categories (Array of Links → Entry) or a short text taxonomy field [depends on your model]
date (Date) [optional]
author (Link → Entry) [optional]
Locale: replace "en-US" with your space’s default locale if different.
Content: your WP content.rendered is HTML. You have two choices:
Store as long text HTML (quickest)
Convert to Contentful Rich Text JSON (best for structured rendering; requires HTML→Rich Text conversion)
Option A — Build a CLI import JSON (contentful space import) This is what a minimal entries-only file could look like after transforming your WP JSON into Contentful export format. Note: referenced IDs must already exist or be included in the same import file.
Then import: contentful space import --space-id <SPACE_ID> --environment-id <ENV_ID> --management-token $CONTENTFUL_CMA_TOKEN --content-only --content-file ./my-import.json
Notes:
The content type blogPost must exist with field IDs that match (title, slug, body, excerpt, date).
If you want to include featured images or categories as references, you must also include those entries/assets in the same import file or pre-create them and link by ID.
Strip runtime sys like createdAt, updatedAt, version.
Option B — Use the CMA REST API (script) and publish in bulk Map each WP post into a CMA entry create body, create entries, then bulk publish. This gives you flexibility to:
Upload/process assets from src URLs
Create author/category entries and link them
Convert HTML to Rich Text if desired
Handle rate limits and retries
TypeScript transformer and CMA create example
This maps your WP JSON object to a Contentful entry body for content type blogPost.
// scripts/wp-to-contentful.tsimport fetch from"node-fetch";
typeWPPost = {
id: number;
slug: string;
date: string;
title: { rendered: string };
content: { rendered: string };
excerpt: { rendered: string };
featured_media?: number;
};
constSPACE_ID = process.env.CONTENTFUL_SPACE_ID!;
constENV_ID = process.env.CONTENTFUL_ENV_ID || "master";
constCMA_TOKEN = process.env.CONTENTFUL_CMA_TOKEN!;
constLOCALE = process.env.CONTENTFUL_LOCALE || "en-US"; // adjust to your space locale// 1) Basic HTML sanitizer for excerpts (optional; keep simple here)functionstripHtml(html: string) {
return html.replace(/<[^>]*>/g, "").trim();
}
// 2) Map a WP post → Contentful fields (HTML stored as long text)functionmapWpToFields(post: WPPost) {
return {
title: { [LOCALE]: post.title?.rendered || "" },
slug: { [LOCALE]: post.slug },
body: { [LOCALE]: post.content?.rendered || "" }, // HTML as long textexcerpt: { [LOCALE]: stripHtml(post.excerpt?.rendered || "") },
date: { [LOCALE]: newDate(post.date).toISOString() },
// Add references like featuredImage, categories, author here if you have their CF IDs// featuredImage: { [LOCALE]: { sys: { type: "Link", linkType: "Asset", id: "ASSET_ID" } } }
};
}
// 3) Create an entryasyncfunctioncreateEntry(contentTypeId: string, fields: any) {
const res = awaitfetch(
`https://api.contentful.com/spaces/${SPACE_ID}/environments/${ENV_ID}/entries`,
{
method: "POST",
headers: {
Authorization: `Bearer ${CMA_TOKEN}`,
"Content-Type": "application/vnd.contentful.management.v1+json",
"X-Contentful-Content-Type": contentTypeId,
},
body: JSON.stringify({ fields }),
}
);
if (!res.ok) {
const text = await res.text();
thrownewError(`Create entry failed: ${res.status}${text}`);
}
return res.json();
}
// 4) Publish a single entryasyncfunctionpublishEntry(entryId: string, version: number) {
const res = awaitfetch(
`https://api.contentful.com/spaces/${SPACE_ID}/environments/${ENV_ID}/entries/${entryId}/published`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${CMA_TOKEN}`,
"X-Contentful-Version": String(version),
},
}
);
if (!res.ok) {
const text = await res.text();
thrownewError(`Publish entry failed: ${res.status}${text}`);
}
return res.json();
}
// 5) Optional: bulk publish helper for scale (send 200 links per bulk action)typeEntityLink = { id: string; linkType: "Entry" | "Asset"; version: number };
asyncfunctionbulkPublish(entities: EntityLink[]) {
const res = awaitfetch(
`https://api.contentful.com/spaces/${SPACE_ID}/environments/${ENV_ID}/bulk_actions/publish`,
{
method: "POST",
headers: {
Authorization: `Bearer ${CMA_TOKEN}`,
"Content-Type": "application/vnd.contentful.management.v1+json",
},
body: JSON.stringify({
entities: {
sys: { type: "Array" },
items: entities.map((e) => ({
sys: { type: "Link", linkType: e.linkType, id: e.id, version: e.version },
})),
},
}),
}
);
if (!res.ok) {
const text = await res.text();
thrownewError(`Bulk publish failed: ${res.status}${text}`);
}
return res.json();
}
// 6) Driver: create entries from a WP arrayexportasyncfunctionimportWpPosts(posts: WPPost[]) {
constcreated: { id: string; version: number }[] = [];
for (const post of posts) {
try {
const fields = mapWpToFields(post);
const entry = awaitcreateEntry("blogPost", fields);
created.push({ id: entry.sys.id, version: entry.sys.version });
// Respect CMA rate limit ~10 rps (simple spacing)awaitnewPromise((r) =>setTimeout(r, 150));
} catch (e) {
console.error(`Failed to create post ${post.id} (${post.slug}):`, e);
}
}
// Publish entries: either one-by-one or bulk// One-by-one:// for (const e of created) await publishEntry(e.id, e.version);// Bulk (recommended; 200 at a time):constchunks: EntityLink[][] = [];
for (let i = 0; i < created.length; i += 200) {
chunks.push(
created.slice(i, i + 200).map((e) => ({ id: e.id, linkType: "Entry", version: e.version }))
);
}
for (const [i, chunk] of chunks.entries()) {
console.log(`Bulk publishing batch ${i + 1}/${chunks.length} (${chunk.length} entries)`);
awaitbulkPublish(chunk);
}
}
How to handle HTML, embeds, shortcodes, and media
If you store HTML in a long text field, you can render it in Next.js with a sanitizer (e.g., DOMPurify) and a custom component to handle:
YouTube iframes: parse and replace with a React component or allowlist iframe src domains
Shortcodes (Contact Form 7, galleries): replace them during transformation with custom blocks or remove them
Inline scripts: strip them (security) and re-implement needed widgets via React
If you want Rich Text:
Convert content.rendered HTML → Contentful Rich Text JSON using a converter (custom or library). This is more work but gives structured rendering and better editor experience.
Linking featured images and categories
Featured image (featured_media): you’ll need to fetch the media from WP, upload to Contentful as an Asset, process, publish, then link it in the entry’s featuredImage field: { "featuredImage": { "en-US": { "sys": { "type": "Link", "linkType": "Asset", "id": "ASSET_ID" } } } }
Categories: either
Create taxonomy entries in Contentful (e.g., Category content type) and link them as references, or
Store as text array (e.g., ["dentisterie-generale"]) if you don’t need references.
If you prefer the CLI import route (export-like JSON)
Build a single file with entries array as shown.
For references, include those entries/assets arrays too, or pre-create them. The CLI expects IDs to resolve within the file or already in space.
Use --content-only if your models already exist.
Which path should you choose?
Use CMA script if you need to transform HTML, fetch/upload media, and create references on the fly (most migrations).
Use CLI import if you can pre-build a complete export-shaped JSON with all entries/assets and your models are stable.
If you share your target Contentful content model (field IDs and types) for the blog/post type, I can tailor the transformer to output exactly the JSON your space expects, including featured image upload and category mapping.
Mark Florkowski
Team
Yeah it seems like the model just “forgot” to include code fences.
I’ll see if we can tweak the system prompt to remind it about formatting, but it’s odd how inconsistent this behaviour is
Amethyst Meteor
Ya that’s a good idea, when I send a follow up prompt for gpt-5 to put the code and JSON into a code block it will format it correctly.
I’ll try chatting with the some of the other gpt-5 versions on t3chat to see if they also have formatting issues.
Log in to comment and vote
Comments6
Cyan Canteen
Yes facing the same issues when using GPT-5 with reason
This is the response
Great — since you already have a Python backend and use React Query + React Hook Form, here’s a lightweight, reusable, frontend-only system that:
- Uses presigned URLs (single-part and multipart) with no AWS SDK in the browser
- Provides drag-and-drop and any-UI triggers via a headless dropzone
- Validates file types and restricts OS file picker types
- Is cross-browser friendly (uses XMLHttpRequest for reliable upload progress)
- Keeps bundle size low (only react-dropzone as a dependency)
- Cleanly integrates with react-query and react-hook-form
What you’ll get below
- A tiny S3 signer adapter for your Python API (pluggable)
- A small, dependency-free uploader with single and multipart (concurrent, retry)
- A headless dropzone you can use with any UI
- Examples with React Query and React Hook Form
Install
- Only one dependency needed for DnD: react-dropzone
- Everything else is plain TS/JS
bash
npm install react-dropzone
1) S3 signer adapter (frontend) for your Python API
- Adjust baseUrl and endpoint names to match your backend.
- Expects Python backend to return presigned URLs for PUT and multipart parts.
- For multipart, we send ETags from S3 responses as-is (including quotes) to your Python complete endpoint.
typescript
// s3Signer.ts
export type S3Signer = {
signPut(params: {
filename: string;
contentType: string;
}): Promise<{
url: string;
key: string;
headers?: Record<string, string>;
method?: "PUT";
}>;
createMultipart(params: {
filename: string;
contentType: string;
}): Promise<{ uploadId: string; key: string }>;
signPart(params: {
key: string;
uploadId: string;
partNumber: number;
}): Promise<{ url: string }>;
completeMultipart(params: {
key: string;
uploadId: string;
parts: { ETag: string; PartNumber: number }[];
}): Promise<{ location?: string }>;
abortMultipart?(params: {
key: string;
uploadId: string;
}): Promise<void>;
};
export function createRestS3Signer(baseUrl = "/api"): S3Signer {
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
credentials: "include",
});
if (!res.ok) {
const msg = await res.text();
throw new Error(`${res.status} ${res.statusText}: ${msg}`);
}
return res.json();
}
return {
async signPut({ filename, contentType }) {
return post<{
method: "PUT";
url: string;
key: string;
headers?: Record<string, string>;
}>("/s3/sign-put", { filename, contentType });
},
async createMultipart({ filename, contentType }) {
return post<{ uploadId: string; key: string }>(
"/s3/multipart/create",
{ filename, contentType },
);
},
async signPart({ key, uploadId, partNumber }) {
return post<{ url: string }>("/s3/multipart/sign-part", {
key,
uploadId,
partNumber,
});
},
async completeMultipart({ key, uploadId, parts }) {
return post<{ location?: string }>("/s3/multipart/complete", {
key,
uploadId,
parts, // ETag must remain as returned by S3 (usually quoted)
});
},
async abortMultipart({ key, uploadId }) {
try {
await post("/s3/multipart/abort", { key, uploadId });
} catch {
// best-effort
}
},
};
}
2) Small, dependency-free uploader with multipart, retries, concurrency
- Uses XMLHttpRequest for reliable upload progress across browsers.
- Single-part for small files; multipart for large files.
- Retries failed part uploads with exponential backoff.
- No AWS SDK on client; presigning handled by your Python API.
typescript
// s3Upload.ts
export type UploadResult = {
key: string;
location?: string;
size: number;
contentType: string;
mode: "single" | "multipart";
};
export type UploadProgress = {
loaded: number;
total: number;
percent: number;
rateBps?: number;
};
export type UploadOptions = {
signer: import("./s3Signer").S3Signer;
multipartThreshold?: number; // bytes; default 100 MiB
partSize?: number; // bytes; min 5 MiB; default 8 MiB
concurrency?: number; // default 4
maxRetries?: number; // default 3
onProgress?: (p: UploadProgress) => void;
signal?: AbortSignal;
};
type PartInfo = {
partNumber: number;
start: number;
end: number;
};
export function guessContentType(file: File): string {
if (file.type) return file.type;
const ext = file.name.split(".").pop()?.toLowerCase();
const map: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
pdf: "application/pdf",
txt: "text/plain",
md: "text/markdown",
csv: "text/csv",
json: "application/json",
zip: "application/zip",
gz: "application/gzip",
mp4: "video/mp4",
mov: "video/quicktime",
mp3: "audio/mpeg",
wav: "audio/wav",
};
return ext && map[ext] ? map[ext] : "application/octet-stream";
}
function xhrUpload(params: {
url: string;
method?: "PUT";
blob: Blob;
headers?: Record<string, string>;
signal?: AbortSignal;
onProgress?: (loaded: number, total: number) => void;
}): Promise<{ etag?: string; status: number }> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(params.method ?? "PUT", params.url, true);
if (params.headers) {
for (const [k, v] of Object.entries(params.headers)) {
xhr.setRequestHeader(k, v);
}
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
params.onProgress?.(e.loaded, e.total);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const etag = xhr.getResponseHeader("ETag") || undefined;
resolve({ etag, status: xhr.status });
} else {
reject(
new Error(
Upload failed with ${xhr.status} ${xhr.statusText}.trim(),),
);
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.onabort = () => reject(new Error("Upload aborted"));
if (params.signal) {
if (params.signal.aborted) {
xhr.abort();
return;
}
const onAbort = () => xhr.abort();
params.signal.addEventListener("abort", onAbort, { once: true });
xhr.addEventListener(
"loadend",
() => params.signal?.removeEventListener("abort", onAbort),
{ once: true },
);
}
xhr.send(params.blob);
});
}
export async function uploadToS3(
file: File,
opts: UploadOptions,
): Promise<UploadResult> {
const {
signer,
multipartThreshold = 100 1024 1024, // 100 MiB
partSize = 8 1024 1024, // 8 MiB
concurrency = 4,
maxRetries = 3,
onProgress,
signal,
} = opts;
const contentType = guessContentType(file);
const total = file.size;
// Global progress state
let loaded = 0;
let lastTickAt = Date.now();
let lastLoaded = 0;
const tick = (delta: number) => {
loaded += delta;
const now = Date.now();
const dt = Math.max(1, now - lastTickAt) / 1000;
const dBytes = loaded - lastLoaded;
const rateBps = dBytes / dt;
lastTickAt = now;
lastLoaded = loaded;
onProgress?.({
loaded,
total,
percent: total ? (loaded / total) * 100 : 0,
rateBps,
});
};
// Single-part
if (file.size <= multipartThreshold) {
const { url, key, headers, method } = await signer.signPut({
filename: file.name,
contentType,
});
await xhrUpload({
url,
method: method ?? "PUT",
blob: file,
headers: { "Content-Type": contentType, ...(headers || {}) },
signal,
onProgress: (l, t) => {
// Since it's one request, l is absolute — but normalize to delta
// This ensures consistent cumulative progress reporting
const delta = l - (loaded || 0);
tick(delta);
},
});
return { key, size: file.size, contentType, mode: "single" };
}
// Multipart
if (signal?.aborted) throw new Error("Upload aborted");
const { uploadId, key } = await signer.createMultipart({
filename: file.name,
contentType,
});
const parts: PartInfo[] = [];
let partNumber = 1;
for (let start = 0; start < file.size; start += partSize) {
const end = Math.min(start + partSize, file.size);
parts.push({ partNumber, start, end });
partNumber += 1;
}
const completed: { ETag: string; PartNumber: number }[] = [];
let hasFailed = false;
const runPart = async (p: PartInfo) => {
if (hasFailed) return;
const attemptUpload = async (attempt: number): Promise<void> => {
if (signal?.aborted) throw new Error("Upload aborted");
const { url } = await signer.signPart({
key,
uploadId,
partNumber: p.partNumber,
});
const blob = file.slice(p.start, p.end);
try {
const before = loaded;
const res = await xhrUpload({
url,
method: "PUT",
blob,
headers: {},
signal,
onProgress: (l, t) => {
// l is partial loaded for this part; compute delta from prev
const delta = p.start === 0 ? l : l; // local to part
// For simplicity, approximate delta changes:
// Use l + previous completed part deltas won't be known here;
// instead we add small increments using difference from last call.
},
});
// After XHR completes, count full part size towards progress
tick(blob.size - Math.min(blob.size, Math.max(0, loaded - before)));
if (!res.etag) {
throw new Error("Missing ETag in part response");
}
completed.push({ ETag: res.etag, PartNumber: p.partNumber });
} catch (e) {
if (attempt < maxRetries) {
const backoff = Math.min(2000, 200 2 * (attempt - 1));
await new Promise((r) => setTimeout(r, backoff));
return attemptUpload(attempt + 1);
}
throw e;
}
};
await attemptUpload(1);
};
// Simple pool
const queue = parts.slice();
const workers: Promise<void>[] = [];
for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
workers.push(
(async function worker() {
while (queue.length && !hasFailed) {
const next = queue.shift();
if (!next) break;
try {
await runPart(next);
} catch (e) {
hasFailed = true;
break;
}
}
})(),
);
}
try {
await Promise.all(workers);
if (hasFailed) throw new Error("One or more parts failed");
// Ensure parts are sorted by PartNumber
completed.sort((a, b) => a.PartNumber - b.PartNumber);
const { location } = await signer.completeMultipart({
key,
uploadId,
parts: completed,
});
return {
key,
location,
size: file.size,
contentType,
mode: "multipart",
};
} catch (e) {
await signer.abortMultipart?.({ key, uploadId });
throw e;
}
}
3) Headless dropzone (drag-and-drop + “any UI” trigger)
- Use any UI library. You get root/input props and events.
- Limits file types in the OS dialog (accept) and validates after selection.
- Keeps bundle light: react-dropzone is small and battle-tested.
typescript
// HeadlessFileDrop.tsx
import React from "react";
import {
useDropzone,
type Accept as DropAccept,
type FileRejection,
} from "react-dropzone";
export type FileDropProps = {
accept?: string[]; // e.g., ["image/*", ".pdf"]
maxSize?: number; // bytes; optional
multiple?: boolean;
onFiles: (files: File[]) => void;
onReject?: (rejections: FileRejection[]) => void;
children: (args: {
getRootProps: ReturnType<typeof useDropzone>["getRootProps"];
getInputProps: ReturnType<typeof useDropzone>["getInputProps"];
isDragActive: boolean;
isFocused: boolean;
isDragReject: boolean;
openFileDialog: () => void;
}) => React.ReactNode;
};
function toDropzoneAccept(accept?: string[]): DropAccept | undefined {
if (!accept || accept.length === 0) return undefined;
// react-dropzone wants a map of mime -> extensions. We map each token
// to either mime or extension buckets.
const map: Record<string, string[]> = {};
for (const t of accept) {
if (t.startsWith(".")) {
// attach to / bucket
map["*/*"] = map["*/*"] || [];
map["*/*"].push(t.toLowerCase());
} else {
map[t] = map[t] || [];
}
}
return map;
}
export function FileDrop(props: FileDropProps) {
const {
accept,
maxSize,
multiple = true,
onFiles,
onReject,
children,
} = props;
const dz = useDropzone({
noClick: true, // we will trigger open via children
multiple,
maxSize,
accept: toDropzoneAccept(accept),
onDropAccepted: (files) => onFiles(files),
onDropRejected: (rej) => onReject?.(rej),
});
return (
<>
{children({
getRootProps: dz.getRootProps,
getInputProps: dz.getInputProps,
isDragActive: dz.isDragActive,
isFocused: dz.isFocused,
isDragReject: dz.isDragReject,
openFileDialog: dz.open,
})}
</>
);
}
4) A reusable uploader hook that wires everything together
- Validates file types and sizes before upload.
- Exposes progress and status per file.
- Lets you plug your Python signer easily.
typescript
// useS3Uploader.ts
import { useCallback, useMemo, useRef, useState } from "react";
import { uploadToS3, type UploadOptions, type UploadResult } from "./s3Upload";
import type { S3Signer } from "./s3Signer";
export type QueuedItem = {
id: string;
file: File;
status:
| "idle"
| "uploading"
| "success"
| "error"
| "canceled";
progress: number; // 0..100
rateBps?: number;
result?: UploadResult;
error?: string;
};
export type UploaderConfig = {
signer: S3Signer;
accept?: string[];
maxFileSize?: number; // bytes
multipartThreshold?: number;
partSize?: number;
concurrency?: number;
maxRetries?: number;
};
const randomId = () =>
Math.random().toString(36).slice(2) + Date.now().toString(36);
export function useS3Uploader(cfg: UploaderConfig) {
const [items, setItems] = useState<QueuedItem[]>([]);
const aborts = useRef<Record<string, AbortController>>({});
const validate = useCallback(
(file: File) => {
if (cfg.maxFileSize && file.size > cfg.maxFileSize) {
return `File too large (max ${
Math.round(cfg.maxFileSize / (1024 * 1024))
} MiB)`;
}
if (cfg.accept && cfg.accept.length) {
const ok = cfg.accept.some((rule) => {
if (rule.startsWith(".")) {
return file.name.toLowerCase().endsWith(rule.toLowerCase());
}
if (rule.endsWith("/*")) {
return file.type.startsWith(rule.slice(0, -1));
}
return file.type === rule;
});
if (!ok) return "File type not allowed";
}
return null;
},
[cfg.maxFileSize, cfg.accept],
);
const enqueue = useCallback(
async (files: File[]) => {
const added: QueuedItem[] = [];
for (const file of files) {
const err = validate(file);
const id = randomId();
added.push({
id,
file,
status: err ? "error" : "idle",
progress: err ? 0 : 0,
error: err ?? undefined,
});
}
setItems((prev) => [...added, ...prev]);
return added.filter((i) => !i.error).map((i) => i.id);
},
[validate],
);
const start = useCallback(
async (id: string) => {
setItems((prev) =>
prev.map((it) =>
it.id === id ? { ...it, status: "uploading", error: undefined } : it,
),
);
const it = items.find((x) => x.id === id);
if (!it) return;
const ac = new AbortController();
aborts.current[id] = ac;
try {
const result = await uploadToS3(it.file, {
signer: cfg.signer,
multipartThreshold: cfg.multipartThreshold,
partSize: cfg.partSize,
concurrency: cfg.concurrency,
maxRetries: cfg.maxRetries,
signal: ac.signal,
onProgress: (p) => {
setItems((prev) =>
prev.map((x) =>
x.id === id
? {
...x,
progress: p.percent,
rateBps: p.rateBps,
}
: x,
),
);
},
} as UploadOptions);
setItems((prev) =>
prev.map((x) =>
x.id === id
? { ...x, status: "success", result, progress: 100 }
: x,
),
);
} catch (e: any) {
const msg =
e?.message ||
(ac.signal.aborted ? "Upload canceled" : "Upload failed");
setItems((prev) =>
prev.map((x) =>
x.id === id
? { ...x, status: "error", error: msg }
: x,
),
);
} finally {
delete aborts.current[id];
}
},
[cfg, items],
);
const startAll = useCallback(async () => {
const startable = items.filter((i) => i.status === "idle");
for (const it of startable) {
// Fire and forget; let state update
void start(it.id);
}
}, [items, start]);
const cancel = useCallback((id: string) => {
aborts.current[id]?.abort();
}, []);
const remove = useCallback((id: string) => {
aborts.current[id]?.abort();
setItems((prev) => prev.filter((x) => x.id !== id));
}, []);
return useMemo(
() => ({
items,
enqueue,
start,
startAll,
cancel,
remove,
}),
[items, enqueue, start, startAll, cancel, remove],
);
}
5) Example: Use any UI with the headless dropzone and uploader
- The drop area and button come from your design system.
- Accepts images and PDFs only; restricts OS picker and validates.
typescript
// UploaderWidget.tsx
import React from "react";
import { FileDrop } from "./HeadlessFileDrop";
import { useS3Uploader } from "./useS3Uploader";
import { createRestS3Signer } from "./s3Signer";
const signer = createRestS3Signer("/api"); // adjust to your backend
export function UploaderWidget() {
const uploader = useS3Uploader({
signer,
accept: ["image/*", ".pdf"],
maxFileSize: 1024 1024 1024, // 1 GiB
multipartThreshold: 100 1024 1024,
partSize: 8 1024 1024,
concurrency: 4,
maxRetries: 3,
});
return (
<FileDrop
accept={["image/*", ".pdf"]}
multiple
onFiles={async (files) => {
const ids = await uploader.enqueue(files);
// Auto-start new items
for (const id of ids) uploader.start(id);
}}
onReject={(rej) => {
// surface reasons to user
console.warn("Rejected:", rej);
}}
>
{({
getRootProps,
getInputProps,
isDragActive,
openFileDialog,
}) => (
<div
{...getRootProps({
className:
"border border-dashed rounded p-4 flex flex-col items-center",
})}
>
<input {...getInputProps()} />
<p>
{isDragActive
? "Drop files here..."
: "Drag and drop files here"}
</p>
<button
type="button"
onClick={openFileDialog}
className="mt-2 inline-flex items-center px-3 py-2 rounded bg-blue-600 text-white"
>
Select files
</button>
<ul className="mt-4 w-full space-y-2">
{uploader.items.map((it) => (
<li
key={it.id}
className="border rounded p-2 flex items-center justify-between"
>
<div className="min-w-0">
<div className="truncate text-sm">
{it.file.name} ({Math.round(it.file.size / 1024)} KB)
</div>
<div className="h-2 bg-gray-200 rounded mt-1">
<div
className="h-2 bg-green-500 rounded"
style={{ width:
${it.progress.toFixed(1)}%}}/>
</div>
<div className="text-xs text-gray-500 mt-1">
{it.status}{" "}
{it.rateBps
?
• ${Math.round(it.rateBps / 1024)} KB/s: ""}
{it.error ?
• ${it.error}: ""}</div>
</div>
<div className="ml-4 shrink-0 space-x-2">
{it.status === "uploading" ? (
<button
className="text-red-600"
onClick={() => uploader.cancel(it.id)}
>
Cancel
</button>
) : it.status === "success" ? (
<button
className="text-gray-600"
onClick={() => uploader.remove(it.id)}
>
Remove
</button>
) : it.status === "error" || it.status === "idle" ? (
<button
className="text-blue-600"
onClick={() => uploader.start(it.id)}
>
Start
</button>
) : null}
</div>
</li>
))}
</ul>
</div>
)}
</FileDrop>
);
}
6) React Query and React Hook Form integration
- If you prefer react-query to orchestrate each upload:
typescript
// useUploadMutation.ts
import { useMutation } from "@tanstack/react-query";
import { uploadToS3 } from "./s3Upload";
import type { S3Signer } from "./s3Signer";
export function useUploadMutation(signer: S3Signer) {
return useMutation({
mutationFn: (file: File) =>
uploadToS3(file, { signer }),
});
}
- React Hook Form field that uploads on select and stores S3 keys:
typescript
// RHFFileField.tsx
import React from "react";
import { Controller, type Control } from "react-hook-form";
import { FileDrop } from "./HeadlessFileDrop";
import { useS3Uploader } from "./useS3Uploader";
import { createRestS3Signer } from "./s3Signer";
type RHFFileFieldProps<T> = {
control: Control<T>;
name: any;
accept?: string[];
label?: string;
maxFileSize?: number;
};
export function RHFFileField<T>({
control,
name,
accept = ["image/*", ".pdf"],
label = "Upload files",
maxFileSize = 1024 1024 512,
}: RHFFileFieldProps<T>) {
const uploader = useS3Uploader({
signer: createRestS3Signer("/api"),
accept,
maxFileSize,
});
return (
<Controller
control={control}
name={name}
render={({ field: { value, onChange } }) => (
<div>
<label className="block mb-2">{label}</label>
<FileDrop
accept={accept}
multiple
onFiles={async (files) => {
const ids = await uploader.enqueue(files);
for (const id of ids) {
uploader.start(id);
}
}}
>
{({ getRootProps, getInputProps, openFileDialog }) => (
<div
{...getRootProps({
className:
"border border-dashed rounded p-4 text-sm",
})}
>
<input {...getInputProps()} />
Drag files or{" "}
<button
type="button"
className="text-blue-600 underline"
onClick={openFileDialog}
>
browse
</button>
</div>
)}
</FileDrop>
<ul className="mt-2 space-y-1">
{uploader.items.map((it) => (
<li key={it.id} className="text-sm">
{it.file.name} — {it.status} {it.progress.toFixed(0)}%
{it.status === "success" && it.result ? (
<button
className="ml-2 text-green-600"
onClick={() =>
onChange([...(value || []), it.result?.key])
}
>
Add to form
</button>
) : null}
</li>
))}
</ul>
</div>
)}
/>
);
}
Important notes and edge cases covered
- File type validation: enforced in both the OS dialog (accept) and after selection.
- Cross-browser upload progress: use XMLHttpRequest rather than fetch for upload progress. Works reliably in Chrome, Firefox, Safari, Edge.
- Multipart details:
- S3 requires parts of at least 5 MiB except the last; default partSize is 8 MiB.
- We send ETag from the UploadPart response header as-is (usually quoted) to your backend. Your Python code should pass those ETags straight to boto3 complete_multipart_upload.
- Retries with backoff on failed parts, plus overall abort on error.
- Errors:
- 403 SignatureDoesNotMatch or Expired URL: the code bubbles error. If you want automatic re-sign, you can wrap signPart inside the retry loop (already done).
- Cancel: AbortController cleanly cancels in-flight XHRs and we try to abort the multipart server-side.
- Accessibility: react-dropzone handles input focus/keyboard, and you can add aria labels to buttons.
- Bundle size:
- Only react-dropzone is added. No AWS SDK in the browser.
- All logic is small, tree-shakeable TypeScript.
- CORS: Ensure your S3 bucket CORS allows PUT, HEAD, and exposes ETag. Example:
- AllowedMethods: GET, PUT, POST, HEAD
- ExposeHeaders: ETag
- AllowedHeaders includes Content-Type and x-amz-* headers
Adapting to your Python backend
- Endpoints used by createRestS3Signer:
- POST /api/s3/sign-put { filename, contentType } -> { method:"PUT", url, key, headers? }
- POST /api/s3/multipart/create { filename, contentType } -> { uploadId, key }
- POST /api/s3/multipart/sign-part { key, uploadId, partNumber } -> { url }
- POST /api/s3/multipart/complete { key, uploadId, parts:[{ETag, PartNumber}] } -> { location? }
- POST /api/s3/multipart/abort { key, uploadId } -> { ok:true }
- If your paths differ, just tweak createRestS3Signer.
Want chunk pause/resume and even richer UI?
- You can extend the uploader to track per-part progress and implement pause/resume within the same session by caching which parts succeeded and aborting in-flight XHRs, then resuming un-uploaded parts later. The structure above already separates parts and will support that with small changes.
- If you later decide you want a batteries-included UI and resumability across page reloads, Uppy’s @uppy/aws-s3 is the most feature-complete. For now, the above keeps your bundle lean and UX solid.
If you share your exact Python endpoint shapes, I can tailor the signer adapter and add a thin MD5 checksum path if your policy requires it.
Mark Florkowski
This also appears to be bad formatting from the model, it “forgot” to include codeblcok fences in it’s output.
Mark Florkowski
Can you click the “Copy response” button and paste the output here?
this looks like bad output from the model, not a failure in the renderer.
Amethyst Meteor
That makes sense, I haven’t ran into this issue with other models. If it’s related to the model then feel free to close my ticket.
Here’s a full response example, you can ignore most of it. The parts where it didn’t put it in markdown when it usually would render in markdown are the sections below “Option A — Build a CLI import JSON“ and “Linking featured images and categories”:
Great question. Here’s a practical way to convert your WordPress posts JSON into data Contentful can ingest, with two supported paths:
Option A: Convert to the “space export” JSON shape for the Contentful CLI (contentful space import)
Option B: Map each WP post to a CMA REST payload and create/publish programmatically
I’ll show both, plus a TypeScript transformer you can run.
High-level mapping from your WP post to Contentful
Choose or create a Contentful content type, e.g., blogPost, with fields:
title (Symbol)
slug (Symbol, unique)
body (Rich Text or Long text)
excerpt (Long text)
featuredImage (Link → Asset) [optional]
categories (Array of Links → Entry) or a short text taxonomy field [depends on your model]
date (Date) [optional]
author (Link → Entry) [optional]
Locale: replace "en-US" with your space’s default locale if different.
Content: your WP content.rendered is HTML. You have two choices:
Store as long text HTML (quickest)
Convert to Contentful Rich Text JSON (best for structured rendering; requires HTML→Rich Text conversion)
Option A — Build a CLI import JSON (contentful space import) This is what a minimal entries-only file could look like after transforming your WP JSON into Contentful export format. Note: referenced IDs must already exist or be included in the same import file.
Example: my-import.json (for 2 posts) { "entries": [ { "sys": { "id": "wp-3135", "type": "Entry", "contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogPost" } } }, "fields": { "title": { "en-US": "Comment sont crées les gouttières Invisalign?" }, "slug": { "en-US": "comment-sont-crees-les-gouttieres-invisalign-6" }, "body": { "en-US": "<p><strong>English Version</strong></p> ... your HTML ..." }, "excerpt": { "en-US": "English Version À notre bureau, nous avons un scanneur numérique ..." }, "date": { "en-US": "2025-04-15T18:16:20Z" } } }, { "sys": { "id": "wp-3132", "type": "Entry", "contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "blogPost" } } }, "fields": { "title": { "en-US": "Complexe post tryout" }, "slug": { "en-US": "comment-sont-crees-les-gouttieres-invisalign-5" }, "body": { "en-US": "<div class="wp-block-cover" ...> ... your HTML ..." }, "excerpt": { "en-US": "" }, "date": { "en-US": "2025-04-15T18:15:48Z" } } } ] }
Then import: contentful space import
--space-id <SPACE_ID>
--environment-id <ENV_ID>
--management-token $CONTENTFUL_CMA_TOKEN
--content-only
--content-file ./my-import.json
Notes:
The content type blogPost must exist with field IDs that match (title, slug, body, excerpt, date).
If you want to include featured images or categories as references, you must also include those entries/assets in the same import file or pre-create them and link by ID.
Strip runtime sys like createdAt, updatedAt, version.
Option B — Use the CMA REST API (script) and publish in bulk Map each WP post into a CMA entry create body, create entries, then bulk publish. This gives you flexibility to:
Upload/process assets from src URLs
Create author/category entries and link them
Convert HTML to Rich Text if desired
Handle rate limits and retries
TypeScript transformer and CMA create example
This maps your WP JSON object to a Contentful entry body for content type blogPost.
// scripts/wp-to-contentful.ts import fetch from "node-fetch"; type WPPost = { id: number; slug: string; date: string; title: { rendered: string }; content: { rendered: string }; excerpt: { rendered: string }; featured_media?: number; }; const SPACE_ID = process.env.CONTENTFUL_SPACE_ID!; const ENV_ID = process.env.CONTENTFUL_ENV_ID || "master"; const CMA_TOKEN = process.env.CONTENTFUL_CMA_TOKEN!; const LOCALE = process.env.CONTENTFUL_LOCALE || "en-US"; // adjust to your space locale // 1) Basic HTML sanitizer for excerpts (optional; keep simple here) function stripHtml(html: string) { return html.replace(/<[^>]*>/g, "").trim(); } // 2) Map a WP post → Contentful fields (HTML stored as long text) function mapWpToFields(post: WPPost) { return { title: { [LOCALE]: post.title?.rendered || "" }, slug: { [LOCALE]: post.slug }, body: { [LOCALE]: post.content?.rendered || "" }, // HTML as long text excerpt: { [LOCALE]: stripHtml(post.excerpt?.rendered || "") }, date: { [LOCALE]: new Date(post.date).toISOString() }, // Add references like featuredImage, categories, author here if you have their CF IDs // featuredImage: { [LOCALE]: { sys: { type: "Link", linkType: "Asset", id: "ASSET_ID" } } } }; } // 3) Create an entry async function createEntry(contentTypeId: string, fields: any) { const res = await fetch( `https://api.contentful.com/spaces/${SPACE_ID}/environments/${ENV_ID}/entries`, { method: "POST", headers: { Authorization: `Bearer ${CMA_TOKEN}`, "Content-Type": "application/vnd.contentful.management.v1+json", "X-Contentful-Content-Type": contentTypeId, }, body: JSON.stringify({ fields }), } ); if (!res.ok) { const text = await res.text(); throw new Error(`Create entry failed: ${res.status} ${text}`); } return res.json(); } // 4) Publish a single entry async function publishEntry(entryId: string, version: number) { const res = await fetch( `https://api.contentful.com/spaces/${SPACE_ID}/environments/${ENV_ID}/entries/${entryId}/published`, { method: "PUT", headers: { Authorization: `Bearer ${CMA_TOKEN}`, "X-Contentful-Version": String(version), }, } ); if (!res.ok) { const text = await res.text(); throw new Error(`Publish entry failed: ${res.status} ${text}`); } return res.json(); } // 5) Optional: bulk publish helper for scale (send 200 links per bulk action) type EntityLink = { id: string; linkType: "Entry" | "Asset"; version: number }; async function bulkPublish(entities: EntityLink[]) { const res = await fetch( `https://api.contentful.com/spaces/${SPACE_ID}/environments/${ENV_ID}/bulk_actions/publish`, { method: "POST", headers: { Authorization: `Bearer ${CMA_TOKEN}`, "Content-Type": "application/vnd.contentful.management.v1+json", }, body: JSON.stringify({ entities: { sys: { type: "Array" }, items: entities.map((e) => ({ sys: { type: "Link", linkType: e.linkType, id: e.id, version: e.version }, })), }, }), } ); if (!res.ok) { const text = await res.text(); throw new Error(`Bulk publish failed: ${res.status} ${text}`); } return res.json(); } // 6) Driver: create entries from a WP array export async function importWpPosts(posts: WPPost[]) { const created: { id: string; version: number }[] = []; for (const post of posts) { try { const fields = mapWpToFields(post); const entry = await createEntry("blogPost", fields); created.push({ id: entry.sys.id, version: entry.sys.version }); // Respect CMA rate limit ~10 rps (simple spacing) await new Promise((r) => setTimeout(r, 150)); } catch (e) { console.error(`Failed to create post ${post.id} (${post.slug}):`, e); } } // Publish entries: either one-by-one or bulk // One-by-one: // for (const e of created) await publishEntry(e.id, e.version); // Bulk (recommended; 200 at a time): const chunks: EntityLink[][] = []; for (let i = 0; i < created.length; i += 200) { chunks.push( created.slice(i, i + 200).map((e) => ({ id: e.id, linkType: "Entry", version: e.version })) ); } for (const [i, chunk] of chunks.entries()) { console.log(`Bulk publishing batch ${i + 1}/${chunks.length} (${chunk.length} entries)`); await bulkPublish(chunk); } }How to handle HTML, embeds, shortcodes, and media
If you store HTML in a long text field, you can render it in Next.js with a sanitizer (e.g., DOMPurify) and a custom component to handle:
YouTube iframes: parse and replace with a React component or allowlist iframe src domains
Shortcodes (Contact Form 7, galleries): replace them during transformation with custom blocks or remove them
Inline scripts: strip them (security) and re-implement needed widgets via React
If you want Rich Text:
Convert content.rendered HTML → Contentful Rich Text JSON using a converter (custom or library). This is more work but gives structured rendering and better editor experience.
Linking featured images and categories
Featured image (featured_media): you’ll need to fetch the media from WP, upload to Contentful as an Asset, process, publish, then link it in the entry’s featuredImage field: { "featuredImage": { "en-US": { "sys": { "type": "Link", "linkType": "Asset", "id": "ASSET_ID" } } } }
Categories: either
Create taxonomy entries in Contentful (e.g., Category content type) and link them as references, or
Store as text array (e.g., ["dentisterie-generale"]) if you don’t need references.
If you prefer the CLI import route (export-like JSON)
Build a single file with entries array as shown.
For references, include those entries/assets arrays too, or pre-create them. The CLI expects IDs to resolve within the file or already in space.
Use --content-only if your models already exist.
Which path should you choose?
Use CMA script if you need to transform HTML, fetch/upload media, and create references on the fly (most migrations).
Use CLI import if you can pre-build a complete export-shaped JSON with all entries/assets and your models are stable.
If you share your target Contentful content model (field IDs and types) for the blog/post type, I can tailor the transformer to output exactly the JSON your space expects, including featured image upload and category mapping.
Mark Florkowski
Yeah it seems like the model just “forgot” to include code fences.
I’ll see if we can tweak the system prompt to remind it about formatting, but it’s odd how inconsistent this behaviour is
Amethyst Meteor
Ya that’s a good idea, when I send a follow up prompt for gpt-5 to put the code and JSON into a code block it will format it correctly.
I’ll try chatting with the some of the other gpt-5 versions on t3chat to see if they also have formatting issues.