From 85a59b9ba785dbb0298eee9d39ac2b96e5471f57 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Fri, 9 Oct 2026 08:37:04 -0700 Subject: [PATCH 1/4] feat(runtime): every method the editor answers, getStorefront among them The SDK learns the 80 methods PopCraft has been answering through a patch of 1.0.0: text on a path and area type, pages, images and documents, importers and exporters (importMode, importFile, exportRequest, getExportRequest, saveExport), getStorefront for a store-template exporter, presentation audio, and the editor commands (moving, styling, masks, motion, audio, prototypes). Manifests can contribute importers and exporters. The document import command is importDocumentFile: it shared its name with the importFile field, and the field's value replaced the method. --- src/index.ts | 2 +- src/runtime/main.ts | 140 +++++++++++++++++++++++++++++++++++++++++++- src/types.ts | 26 +++++++- 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index d175d73..a5aa89d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ export { buildMainDocument, buildUIDocument, csp } from './host.js' export { mainApiMethods } from './runtime/main.js' export { MAIN_RUNTIME, UI_RUNTIME } from './generated/runtime.js' -export type { PopCraftApi } from './runtime/main.js' +export type { PopCraftApi, PopCraftCommandArgs, PopCraftPresentationAsset, PopCraftPresentationDeck, PopCraftPresentationSettings, PopCraftSlideTiming } from './runtime/main.js' export type { PopCraftUiApi } from './runtime/ui.js' export * from './types.js' diff --git a/src/runtime/main.ts b/src/runtime/main.ts index 7dc762e..cb76a81 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -11,6 +11,31 @@ import type { PopCraftSerialisedWidget, PopCraftWidgetDefinition, PopCraftWidgetHandler, } from '../widget.js' +export interface PopCraftPresentationSettings { + voice: string; voiceModel: 'natural' | 'hd' | 'standard'; speed: number; + style: 'conversational' | 'corporate' | 'energetic' | 'calm' | 'documentary'; + mood: 'uplifting' | 'ambient' | 'cinematic' | 'lofi' | 'electronic' | 'acoustic' | 'none'; + musicModel: 'hq' | 'standard'; stingers: boolean; loudness: number; truePeak: number; beatAlign: boolean; autoAdvance: boolean; + musicDb: number; duckDb: number; stingerDb: number; leadInMs: number; introMs: number; tailMs: number; minSlideMs: number; +} +export interface PopCraftPresentationAsset { + mediaHash: string; name: string; durationMs: number; source: 'generated' | 'imported' | 'plugin' | 'fallback'; + model?: string; loudness?: { lufs: number; truePeak: number }; beats?: number[]; bpm?: number | null; hitMs?: number; +} +export interface PopCraftSlideTiming { + frameId: string; index: number; start: number; duration: number; transitionMs: number; + voiceStart: number | null; voiceMs: number; leaveAfterMs: number; advanceAfterMs: number; hit: number | null; +} +export interface PopCraftPresentationDeck { + pageId: string; soundtrackId: string | null; settings: PopCraftPresentationSettings; total: number; trimDb: number; + slides: { frameId: string; index: number; name: string; notes: string; script: string; animationMs: number; + transition: { type: string; duration: number } | null; voice: PopCraftPresentationAsset | null; stale: boolean; + holdMs: number | null; timing: PopCraftSlideTiming }[]; + music: PopCraftPresentationAsset | null; stinger: PopCraftPresentationAsset | null; +} +/** Arguments of an editor command method: see docs/plugins/methods.md for each one's schema. */ +export type PopCraftCommandArgs = Record; + function clone(v: T): T { return (v === undefined ? {} : JSON.parse(JSON.stringify(v))) as T } @@ -91,6 +116,12 @@ export function createMainApi(rpc: RpcClient = createRpcClient()) { const popcraft = { /** Which menu command the user picked, or null when the plugin ran some other way. */ command: null as string | null, + /** Host import mode when launched as an importer, else null. */ + importMode: null as "new" | "into-current" | null, + /** Pending import file when the host handed one over, else null. */ + importFile: null as { name: string; dataUrl: string; byteLength: number; } | null, + /** Pending export request (File → Export → this plugin's format), else null. */ + exportRequest: null as { exporterId: string; extension: string; name: string; nodeIds: string[]; } | null, getSelection: () => rpc.call('getSelection'), setSelection: (ids: string[]) => rpc.call('setSelection', { ids }), @@ -101,6 +132,108 @@ export function createMainApi(rpc: RpcClient = createRpcClient()) { updateNode: (id: string, props: Partial) => rpc.call('updateNode', { id, props }), deleteNode: (id: string) => rpc.call('deleteNode', { id }), setText: (id: string, characters: string) => rpc.call('setText', { id, characters }), + /** Put TEXT layers on a path (another layer's outline, or their own arc, circle or wave), or tune one: brackets, alignment, placement, effect, overflow. */ + setTextPath: (opts: { ids?: string[]; mode?: 'PATH' | 'ARC' | 'CIRCLE' | 'WAVE' | 'NONE'; pathId?: string; bend?: number; radius?: number; amplitude?: number; frequency?: number; offset?: number; endOffset?: number; flip?: boolean; align?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFY'; placement?: 'BASELINE' | 'ASCENDER' | 'DESCENDER' | 'CENTER'; baselineShift?: number; spacing?: number; effect?: 'RAINBOW' | 'SKEW' | 'RIBBON' | 'STAIR' | 'GRAVITY'; overflow?: 'CLIP' | 'VISIBLE' | 'SHRINK'; }) => rpc.call('setTextPath', opts || {}), + /** Area type: flow a TEXT layer inside a closed shape, kept `inset` px in. */ + flowTextIntoShape: (opts: { id?: string; shapeId?: string; inset?: number; fit?: 'NONE' | 'SHRINK' | 'FIT'; }) => rpc.call('flowTextIntoShape', opts || {}), + /** Take TEXT layers off their path or out of their shape (or free every text linked to a path / shape). */ + detachText: (opts?: { ids?: string[]; }) => rpc.call('detachText', opts || {}), + /** Auto-size TEXT layers' type to their box, shape or path. */ + setTextFit: (opts: { ids?: string[]; fit: 'NONE' | 'SHRINK' | 'FIT'; }) => rpc.call('setTextFit', opts || {}), + createPage: (name?: string) => rpc.call('createPage', name != null ? { name: String(name) } : {}), + setCurrentPage: (pageId: string) => rpc.call('setCurrentPage', { pageId }), + createImage: (opts: { dataUrl?: string; bytes?: number[]; mimeType?: string; name?: string; x?: number; y?: number; width?: number; height?: number; parentId?: string; }) => rpc.call('createImage', opts || {}), + createDocument: (opts?: { name?: string; }) => rpc.call<{ key?: string; docId: string; name: string; }>('createDocument', opts || {}), + getImportMode: () => rpc.call<"new" | "into-current" | null>('getImportMode'), + getImportFile: () => rpc.call<{ name: string; dataUrl: string; byteLength: number; } | null>('getImportFile'), + /** + * The open page as a storefront design in a neutral form, for an exporter that writes its own store's templates: + * each page's HTML with the store's data marked (`{{page.title}}`, `{{item.price}}`, `` lists, + * `data-pc-action` forms), and its collections with their commerce schema and sample records. + */ + getStorefront: () => rpc.call<{ name: string; pages: { name: string; route: string | null; about: string | null; html: string }[]; collections: { id: string; name: string; schema: string | null; fields: string[]; samples: Record[] }[]; }>('getStorefront'), + getExportRequest: () => rpc.call<{ exporterId: string; extension: string; name: string; nodeIds: string[]; } | null>('getExportRequest'), + /** Save the exporter's file (once per export request); the host enforces the contributed extension. */ + saveExport: (file: { name?: string; mimeType?: string; bytes?: Uint8Array | number[]; dataUrl?: string; }) => rpc.call<{ name: string; byteLength: number; }>('saveExport', file || {}), + /** + * Presentation audio (docs/plugins/presentation-audio.md): the deck's slides, notes, narration and timing; the + * soundtrack's settings, bed and stinger. + */ + getPresentationAudio: (opts?: { pageId?: string }) => rpc.call('getPresentationAudio', opts || {}), + /** Change the soundtrack's settings (voice, style, mood, loudness, beat alignment, levels) and re-time it. */ + setPresentationAudioSettings: (opts: { pageId?: string } & Partial) => rpc.call<{ soundtrackId: string | null; settings: PopCraftPresentationSettings; total: number }>('setPresentationAudioSettings', opts || {}), + /** Put a sound in (a slide's narration, the bed or the stinger): bytes are normalised to the soundtrack's target and stored. */ + setPresentationAudioClip: (opts: { pageId?: string; role: 'voice' | 'music' | 'stinger'; frameId?: string; bytes?: Uint8Array | number[]; mediaHash?: string; url?: string; name?: string; normalize?: boolean; bpm?: number; beats?: number[]; hitMs?: number }) => rpc.call<{ soundtrackId: string | null; asset: PopCraftPresentationAsset; total: number; mix: { lufs: number; truePeak: number } | null }>('setPresentationAudioClip', opts || {}), + removePresentationAudioClip: (opts: { pageId?: string; role: 'voice' | 'music' | 'stinger'; frameId?: string }) => rpc.call<{ total: number }>('removePresentationAudioClip', opts || {}), + /** Re-time: fixed slide lengths (ms, null clears) and beat-aligned changes. */ + setPresentationTiming: (opts: { pageId?: string; holds?: Record; beatAlign?: boolean }) => rpc.call<{ total: number; slides: PopCraftSlideTiming[] }>('setPresentationTiming', opts || {}), + /** The mix as the export carries it: loudness, true peak, and (wav: true) the 48 kHz stereo WAV. */ + getPresentationMix: (opts?: { pageId?: string; wav?: boolean; range?: [number, number] }) => rpc.call<{ lufs: number | null; truePeak: number | null; target?: number; ceiling?: number; durationMs: number; sampleRate?: number; wav?: Uint8Array }>('getPresentationMix', opts || {}), + /** Ask the user to generate with AI (the panel opens with the request filled in; the user decides). */ + requestPresentationAudio: (opts?: { pageId?: string; what?: 'all' | 'voice' | 'music' | 'stingers'; slides?: string[] } & Partial) => rpc.call<{ status: 'generated'; summary: string } | { status: 'declined' } | { status: 'failed'; error: string }>('requestPresentationAudio', opts || {}), + /** Editor commands: each takes the arguments documented in docs/plugins/methods.md (the agent tool of the same purpose). */ + moveNodes: (opts?: PopCraftCommandArgs) => rpc.call('moveNodes', opts || {}), + resizeNodes: (opts?: PopCraftCommandArgs) => rpc.call('resizeNodes', opts || {}), + renameNodes: (opts?: PopCraftCommandArgs) => rpc.call('renameNodes', opts || {}), + reparentNodes: (opts?: PopCraftCommandArgs) => rpc.call('reparentNodes', opts || {}), + duplicateNodes: (opts?: PopCraftCommandArgs) => rpc.call('duplicateNodes', opts || {}), + groupNodes: (opts?: PopCraftCommandArgs) => rpc.call('groupNodes', opts || {}), + ungroupNodes: (opts?: PopCraftCommandArgs) => rpc.call('ungroupNodes', opts || {}), + alignNodes: (opts?: PopCraftCommandArgs) => rpc.call('alignNodes', opts || {}), + distributeNodes: (opts?: PopCraftCommandArgs) => rpc.call('distributeNodes', opts || {}), + flipNodes: (opts?: PopCraftCommandArgs) => rpc.call('flipNodes', opts || {}), + rotateNodes: (opts?: PopCraftCommandArgs) => rpc.call('rotateNodes', opts || {}), + reorderNodes: (opts?: PopCraftCommandArgs) => rpc.call('reorderNodes', opts || {}), + setLocked: (opts?: PopCraftCommandArgs) => rpc.call('setLocked', opts || {}), + setVisible: (opts?: PopCraftCommandArgs) => rpc.call('setVisible', opts || {}), + setAutoLayout: (opts?: PopCraftCommandArgs) => rpc.call('setAutoLayout', opts || {}), + importSvg: (opts?: PopCraftCommandArgs) => rpc.call('importSvg', opts || {}), + measureNodes: (opts?: PopCraftCommandArgs) => rpc.call('measureNodes', opts || {}), + setFills: (opts?: PopCraftCommandArgs) => rpc.call('setFills', opts || {}), + setStrokes: (opts?: PopCraftCommandArgs) => rpc.call('setStrokes', opts || {}), + setEffects: (opts?: PopCraftCommandArgs) => rpc.call('setEffects', opts || {}), + replaceColor: (opts?: PopCraftCommandArgs) => rpc.call('replaceColor', opts || {}), + setTypography: (opts?: PopCraftCommandArgs) => rpc.call('setTypography', opts || {}), + applyStyle: (opts?: PopCraftCommandArgs) => rpc.call('applyStyle', opts || {}), + createComponent: (opts?: PopCraftCommandArgs) => rpc.call('createComponent', opts || {}), + createInstance: (opts?: PopCraftCommandArgs) => rpc.call('createInstance', opts || {}), + renamePage: (opts?: PopCraftCommandArgs) => rpc.call('renamePage', opts || {}), + getDocumentOutline: (opts?: PopCraftCommandArgs) => rpc.call('getDocumentOutline', opts || {}), + setPageSetup: (opts?: PopCraftCommandArgs) => rpc.call('setPageSetup', opts || {}), + setShowAdvance: (opts?: PopCraftCommandArgs) => rpc.call('setShowAdvance', opts || {}), + booleanOperation: (opts?: PopCraftCommandArgs) => rpc.call('booleanOperation', opts || {}), + flattenNodes: (opts?: PopCraftCommandArgs) => rpc.call('flattenNodes', opts || {}), + outlineStroke: (opts?: PopCraftCommandArgs) => rpc.call('outlineStroke', opts || {}), + setMask: (opts?: PopCraftCommandArgs) => rpc.call('setMask', opts || {}), + setMaskType: (opts?: PopCraftCommandArgs) => rpc.call('setMaskType', opts || {}), + shapeBuilder: (opts?: PopCraftCommandArgs) => rpc.call('shapeBuilder', opts || {}), + setKeyframes: (opts?: PopCraftCommandArgs) => rpc.call('setKeyframes', opts || {}), + getAnimation: (opts?: PopCraftCommandArgs) => rpc.call('getAnimation', opts || {}), + getCompositions: (opts?: PopCraftCommandArgs) => rpc.call('getCompositions', opts || {}), + editBehaviours: (opts?: PopCraftCommandArgs) => rpc.call('editBehaviours', opts || {}), + setTimeline: (opts?: PopCraftCommandArgs) => rpc.call('setTimeline', opts || {}), + setLayerTiming: (opts?: PopCraftCommandArgs) => rpc.call('setLayerTiming', opts || {}), + editTextAnimation: (opts?: PopCraftCommandArgs) => rpc.call('editTextAnimation', opts || {}), + edit3DScene: (opts?: PopCraftCommandArgs) => rpc.call('edit3DScene', opts || {}), + setInstancePlayback: (opts?: PopCraftCommandArgs) => rpc.call('setInstancePlayback', opts || {}), + editComponentProperty: (opts?: PopCraftCommandArgs) => rpc.call('editComponentProperty', opts || {}), + setShaderFill: (opts?: PopCraftCommandArgs) => rpc.call('setShaderFill', opts || {}), + createComposition: (opts?: PopCraftCommandArgs) => rpc.call('createComposition', opts || {}), + editComposition: (opts?: PopCraftCommandArgs) => rpc.call('editComposition', opts || {}), + editStateMachine: (opts?: PopCraftCommandArgs) => rpc.call('editStateMachine', opts || {}), + getStateMachine: (opts?: PopCraftCommandArgs) => rpc.call('getStateMachine', opts || {}), + applyMotionPreset: (opts?: PopCraftCommandArgs) => rpc.call('applyMotionPreset', opts || {}), + staggerMotion: (opts?: PopCraftCommandArgs) => rpc.call('staggerMotion', opts || {}), + createMotionComponent: (opts?: PopCraftCommandArgs) => rpc.call('createMotionComponent', opts || {}), + addAudio: (opts?: PopCraftCommandArgs) => rpc.call('addAudio', opts || {}), + editFrameAudio: (opts?: PopCraftCommandArgs) => rpc.call('editFrameAudio', opts || {}), + editVideoClip: (opts?: PopCraftCommandArgs) => rpc.call('editVideoClip', opts || {}), + detectAudioOnsets: (opts?: PopCraftCommandArgs) => rpc.call('detectAudioOnsets', opts || {}), + cutToBeat: (opts?: PopCraftCommandArgs) => rpc.call('cutToBeat', opts || {}), + editPrototype: (opts?: PopCraftCommandArgs) => rpc.call('editPrototype', opts || {}), + usePresentationFallbackAudio: (opts?: PopCraftCommandArgs) => rpc.call('usePresentationFallbackAudio', opts || {}), + /** Bring a Lottie, PDF or PopCraft file into the open document (the `importFile` field is the file an importer was handed). */ + importDocumentFile: (opts?: PopCraftCommandArgs) => rpc.call('importDocumentFile', opts || {}), getStyles: () => rpc.call('getStyles'), createPaintStyle: (name: string, paints: PopCraftPaint[]) => rpc.call('createPaintStyle', { name, paints }), @@ -151,7 +284,12 @@ export function createMainApi(rpc: RpcClient = createRpcClient()) { off: (event: E, cb: (data: PopCraftEvents[E]) => void) => rpc.off(event as string, cb), } - rpc.on('init', (data: { command: string | null }) => { popcraft.command = data.command }) + rpc.on('init', (data: { command: string | null; importMode?: typeof popcraft.importMode; importFile?: typeof popcraft.importFile; exportRequest?: typeof popcraft.exportRequest }) => { + popcraft.command = data.command + popcraft.importMode = data.importMode ?? null + popcraft.importFile = data.importFile ?? null + popcraft.exportRequest = data.exportRequest ?? null + }) return popcraft } diff --git a/src/types.ts b/src/types.ts index 7596b7a..6f1aa2b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,9 +38,33 @@ export interface PopCraftThemeContribution { path: string } -/** Static contributions that need no code: they load without running the plugin sandbox. */ +/** A File → Import format this plugin handles. */ +export interface PopCraftImporterContribution { + id: string + label: string + /** Extensions (`.pub`) and/or MIME types. */ + accept: string[] + /** Command run with `importMode` / `importFile` set. Defaults to the first command. */ + command?: string +} +/** A File → Export format this plugin writes. */ +export interface PopCraftExporterContribution { + id: string + label: string + /** The saved file's extension with its dot (`.ai`). */ + extension: string + /** Command run with `exportRequest` set. Defaults to the first command. */ + command?: string +} + +/** + * What a plugin adds besides commands: themes (static, loaded without running the sandbox), and the File → Import and + * File → Export formats it handles. + */ export interface PopCraftContributions { themes?: PopCraftThemeContribution[] + importers?: PopCraftImporterContribution[] + exporters?: PopCraftExporterContribution[] } export interface PopCraftManifest { From fbf2f4240165892129053b1d4110fb871fc6a7d7 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Fri, 9 Oct 2026 08:37:04 -0700 Subject: [PATCH 2/4] feat(examples): a storefront preview exporter built on getStorefront MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File → Export → Storefront preview saves a storefront design as one HTML file to share, every page drawn with its sample products: the round trip a store-template plugin makes, with the README on importers and exporters. --- README.md | 23 ++++++- examples/plugins/storefront-preview/main.js | 68 +++++++++++++++++++ .../plugins/storefront-preview/manifest.json | 24 +++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 examples/plugins/storefront-preview/main.js create mode 100644 examples/plugins/storefront-preview/manifest.json diff --git a/README.md b/README.md index a0594f0..a3b92fe 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ One folder per marketplace kind: | Folder | Kind | Examples | | --- | --- | --- | -| [`plugins/`](examples/plugins) | Plugins | [`arrange-grid`](examples/plugins/arrange-grid) (commands, a UI, storage), [`rename-layers`](examples/plugins/rename-layers) (UI panel, events, storage), [`contrast-checker`](examples/plugins/contrast-checker) (WCAG checks, `findNodes`, `setSelection`), [`pop-palette`](examples/plugins/pop-palette) (paint styles) | +| [`plugins/`](examples/plugins) | Plugins | [`arrange-grid`](examples/plugins/arrange-grid) (commands, a UI, storage), [`rename-layers`](examples/plugins/rename-layers) (UI panel, events, storage), [`contrast-checker`](examples/plugins/contrast-checker) (WCAG checks, `findNodes`, `setSelection`), [`pop-palette`](examples/plugins/pop-palette) (paint styles), [`storefront-preview`](examples/plugins/storefront-preview) (a File → Export format built on `getStorefront()`) | | [`widgets/`](examples/widgets) | Canvas widgets (plugins that register a widget) | [`vote-counter`](examples/widgets/vote-counter), [`poll`](examples/widgets/poll), [`checklist`](examples/widgets/checklist) (named handlers) | | [`themes/`](examples/themes) | Theme packs (data-only plugins) | [`comic-themes`](examples/themes/comic-themes) (two light themes), [`comic-noir`](examples/themes/comic-noir) (semi-dark) | | [`brushes/`](examples/brushes) | Brush packs | [`comic-inkers`](examples/brushes/comic-inkers): liner, brush pen, stipple | @@ -51,6 +51,27 @@ Install one from **Main menu → Manage plugins… → Install from file…**, w folder (`manifest.json` at the root) or a single-file `.json` bundle: `{ "manifest": { … }, "main": "…code…", "ui": "…" }`. +## Importers and exporters + +A plugin can add a File → Import or File → Export format in its manifest: + +```json +"contributes": { + "importers": [{ "id": "pub", "label": "Publisher (.pub)", "accept": [".pub"] }], + "exporters": [{ "id": "ai", "label": "Adobe Illustrator (.ai)", "extension": ".ai" }] +} +``` + +An importer runs with `popcraft.importMode` and `popcraft.importFile` set, and builds the document with the +editor methods. An exporter runs with `popcraft.exportRequest` set, renders what it needs with `exportNode`, and +hands the file back with `popcraft.saveExport({ name, bytes })`. + +An exporter for a store reads the open page as a storefront with `popcraft.getStorefront()`: each page's HTML with +the store's data marked rather than filled (`{{page.title}}`, `{{item.price}}`, lists between +`` and ``, forms with +`data-pc-action`), and the collections it reads with their schema and sample records. It turns those marks into its +platform's templates; [`storefront-preview`](examples/plugins/storefront-preview) fills them with the samples instead. + ## Publishing to the marketplace Each item folder holds one of `manifest.json` (a plugin, widget or theme pack), `pack.json` (a brush or diff --git a/examples/plugins/storefront-preview/main.js b/examples/plugins/storefront-preview/main.js new file mode 100644 index 0000000..0003964 --- /dev/null +++ b/examples/plugins/storefront-preview/main.js @@ -0,0 +1,68 @@ +/// + +// Storefront Preview — an exporter (File → Export → Storefront preview) built on popcraft.getStorefront(). +// +// getStorefront() hands a plugin the open page's store design with the store's data marked, not filled: +// - a field of the record a page is about: {{page.title}} +// - a list, around its one copy: … +// - a field inside a list's copy: {{item.price}} +// - a storefront form:
+// A store-template plugin turns those marks into its platform's template language (Liquid, Handlebars, PHP). +// This one fills them with the design's own sample records instead, so the store can be shared as one file. + +const escapeHtml = s => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +/** `html` with every {{scope.key}} filled from `record` (a field it lacks is left empty). */ +function fill(html, scope, record) { + return html.replace(new RegExp(`\\{\\{${scope}\\.([\\w]+)\\}\\}`, 'g'), (_m, key) => escapeHtml(record?.[key])) +} + +/** Each list drawn once per sample record of its collection, within its limit and offset. */ +function expandLists(html, collections) { + const each = /([\s\S]*?)/ + let out = html + for (let guard = 0; guard < 200 && each.test(out); guard++) { + out = out.replace(each, (_m, list, attrs, copy) => { + const n = name => Number((attrs.match(new RegExp(`${name}="(\\d+)"`)) || [])[1] || 0) + const offset = n('offset'), limit = n('limit') + const rows = (collections.find(c => c.name === list)?.samples ?? []).slice(offset, limit ? offset + limit : undefined) + return rows.map(row => fill(copy, 'item', row)).join('\n') + }) + } + return out +} + +/** A storefront form does nothing in a preview: it says so instead of posting. */ +const quietForms = html => html.replace(/]*) data-pc-action="(\w+)"/g, ' { + const request = popcraft.exportRequest || (await popcraft.getExportRequest()) + const store = await popcraft.getStorefront() + if (!store.pages.length) { + await popcraft.notify('This page has no screens to export.', { error: true }) + return popcraft.closePlugin() + } + const pages = store.pages.map((page, i) => { + // A page about a record (a product's page) is drawn with the first sample of its collection. + const about = page.about ? store.collections.find(c => c.schema === page.about)?.samples[0] : null + const html = quietForms(fill(expandLists(page.html, store.collections), 'page', about)) + return `

${escapeHtml(page.name)}${page.route ? ` · ${escapeHtml(page.route)}` : ''}

${html}
` + }) + const nav = store.pages.map((p, i) => `${escapeHtml(p.name)}`).join('') + const doc = ` + +${escapeHtml(store.name)} — storefront preview + + +${pages.join('\n')} +` + await popcraft.saveExport({ name: request?.name || `${store.name} storefront.html`, mimeType: 'text/html', bytes: Array.from(new TextEncoder().encode(doc)) }) + await popcraft.notify(`Exported ${store.pages.length} pages of ${store.name}`) + popcraft.closePlugin() +})() diff --git a/examples/plugins/storefront-preview/manifest.json b/examples/plugins/storefront-preview/manifest.json new file mode 100644 index 0000000..dc793f4 --- /dev/null +++ b/examples/plugins/storefront-preview/manifest.json @@ -0,0 +1,24 @@ +{ + "id": "popcraft.storefront-preview", + "name": "Storefront Preview", + "version": "1.0.0", + "description": "Export a storefront design as one HTML file to share: every page of the store, drawn with its sample products. A worked example of a store-template exporter built on getStorefront().", + "author": "PopCraft", + "main": "main.js", + "permissions": [ + "read" + ], + "contributes": { + "exporters": [ + { "id": "storefront-preview", "label": "Storefront preview (.html)", "extension": ".html" } + ] + }, + "category": "export", + "tags": [ + "storefront", + "ecommerce", + "shop", + "export", + "html" + ] +} From 17d37c179a7bbc866b01d54b08cbe841c7a31a42 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Fri, 9 Oct 2026 16:06:57 -0700 Subject: [PATCH 3/4] feat(sdk): plugins edit a design's data, forms and pages, keep settings with the file, and dock as a side tab editCollection, editRecords, getRecords, setControl, setRepeat, setRoute, setSemantics, getSiteEnvironment, setSiteEnvironment, getFileData, setFileData and getStorefrontFiles, as the editor answers them; contributes.panels in the manifest types. --- src/runtime/main.ts | 20 ++++++++++++++++++++ src/types.ts | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/runtime/main.ts b/src/runtime/main.ts index cb76a81..6b40018 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -152,6 +152,26 @@ export function createMainApi(rpc: RpcClient = createRpcClient()) { * `data-pc-action` forms), and its collections with their commerce schema and sample records. */ getStorefront: () => rpc.call<{ name: string; pages: { name: string; route: string | null; about: string | null; html: string }[]; collections: { id: string; name: string; schema: string | null; fields: string[]; samples: Record[] }[]; }>('getStorefront'), + /** + * A design's data (docs: Collections): a collection and its fields (`schema`: what it stands for, e.g. + * `commerce.product`, whose fields are made for it), its records, and reading them. + */ + editCollection: (opts: PopCraftCommandArgs) => rpc.call('editCollection', opts || {}), + editRecords: (opts: PopCraftCommandArgs) => rpc.call('editRecords', opts || {}), + getRecords: (opts?: PopCraftCommandArgs) => rpc.call('getRecords', opts || {}), + /** A layer as a web page's form control (a FORM's `formAction`, an INPUT's name and value), repeated per record, a page's address, its semantics. */ + setControl: (opts: PopCraftCommandArgs) => rpc.call('setControl', opts || {}), + setRepeat: (opts: PopCraftCommandArgs) => rpc.call('setRepeat', opts || {}), + setRoute: (opts: PopCraftCommandArgs) => rpc.call('setRoute', opts || {}), + setSemantics: (opts: PopCraftCommandArgs) => rpc.call('setSemantics', opts || {}), + /** The site's environment: public settings its host reads by name when it is deployed (never a secret: the file keeps them). */ + getSiteEnvironment: () => rpc.call>('getSiteEnvironment'), + setSiteEnvironment: (set: Record) => rpc.call('setSiteEnvironment', { set }), + /** What this plugin keeps with the open file (its settings for this design), under its own id; JSON only. */ + getFileData: (key: string) => rpc.call('getFileData', { key }), + setFileData: (key: string, value: unknown) => rpc.call('setFileData', { key, value }), + /** A store's project built from the open page, zipped (needs `export`): what an exporter saves with `saveExport`. */ + getStorefrontFiles: (target: 'shopify-theme' | 'next-commerce' | 'next-commerce-store') => rpc.call<{ name: string; files: number; bytes: Uint8Array }>('getStorefrontFiles', { target }), getExportRequest: () => rpc.call<{ exporterId: string; extension: string; name: string; nodeIds: string[]; } | null>('getExportRequest'), /** Save the exporter's file (once per export request); the host enforces the contributed extension. */ saveExport: (file: { name?: string; mimeType?: string; bytes?: Uint8Array | number[]; dataUrl?: string; }) => rpc.call<{ name: string; byteLength: number; }>('saveExport', file || {}), diff --git a/src/types.ts b/src/types.ts index 6f1aa2b..bebf62c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,8 +61,20 @@ export interface PopCraftExporterContribution { * What a plugin adds besides commands: themes (static, loaded without running the sandbox), and the File → Import and * File → Export formats it handles. */ +/** A panel docked as a tab on the side of the editor, running the plugin's UI while it is open. */ +export interface PopCraftPanelContribution { + id: string + title: string + /** A data: URL or a path in the package (svg, png or webp). */ + icon?: string + side?: 'left' | 'right' + /** The command the panel starts the plugin with (default: the panel's id). */ + command?: string +} + export interface PopCraftContributions { themes?: PopCraftThemeContribution[] + panels?: PopCraftPanelContribution[] importers?: PopCraftImporterContribution[] exporters?: PopCraftExporterContribution[] } From 6f536d08b82245a2a818f8647bde79e949d579f1 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Fri, 9 Oct 2026 16:06:58 -0700 Subject: [PATCH 4/4] feat(examples): the Ecommerce plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Store tab that connects a Shopify store and browses its catalogue by category, product or search; places its pictures or fills the design's products with them; makes any design a store and checks what it still needs, fixing a store form's hidden field itself; and exports a Shopify theme. It sets the site environment Publish › Store deploys a Next.js Commerce store with. --- examples/plugins/ecommerce/README.md | 25 ++ examples/plugins/ecommerce/icon.svg | 1 + examples/plugins/ecommerce/main.js | 293 +++++++++++++++++++++++ examples/plugins/ecommerce/manifest.json | 21 ++ examples/plugins/ecommerce/ui.html | 142 +++++++++++ 5 files changed, 482 insertions(+) create mode 100644 examples/plugins/ecommerce/README.md create mode 100644 examples/plugins/ecommerce/icon.svg create mode 100644 examples/plugins/ecommerce/main.js create mode 100644 examples/plugins/ecommerce/manifest.json create mode 100644 examples/plugins/ecommerce/ui.html diff --git a/examples/plugins/ecommerce/README.md b/examples/plugins/ecommerce/README.md new file mode 100644 index 0000000..c13b51d --- /dev/null +++ b/examples/plugins/ecommerce/README.md @@ -0,0 +1,25 @@ +# Ecommerce + +A store for a storefront design. Everything about Shopify lives in this plugin, none of it in the editor: + +- **Store** (a tab on the side of the editor): connect a Shopify store by its `.myshopify.com` address and the public + Storefront access token (Shopify admin › Sales channels › Headless › your storefront › Storefront API). It is kept + with the file, and set as the site environment (`SHOPIFY_STORE_DOMAIN`, `SHOPIFY_STOREFRONT_ACCESS_TOKEN`) that + **Publish › Store** deploys a Next.js Commerce store with. +- **Catalogue**: the store's products as pictures, by category, a product's handle or a search, sorted and limited. + Click a picture to place it on the page; **Use in the design** fills the design's products (and categories) with + them, so the canvas shows what the store sells. +- **Make it a store**: the collections a store needs (products, categories, cart, cart lines, menu), with samples, + or the store's own products when one is connected. +- **Checklist**: what the design still needs to sell, the layer to look at, and **Fix** where the plugin can do it + (a store form's hidden field: add to cart sends `{{item.variant_id}}`, a cart line's form `{{item.merchandise_id}}`). +- **Export › Shopify theme (.zip)**: an Online Store 2.0 theme of the design, to upload in Online Store › Themes. + +## How it is built + +- `contributes.panels` docks the UI (`ui.html`) as the Store tab; `contributes.exporters` adds the theme to Export. +- The catalogue is read straight from the store's Storefront API (it answers any origin), with the `network` + permission. Nothing goes through PopCraft's servers. +- The design is changed with the editor's own commands: `editCollection`, `editRecords`, `getRecords`, `setControl`, + `createNode`, `createImage`, `setSiteEnvironment`; its settings are kept with `setFileData`. +- The theme is the editor's own Shopify theme target (`getStorefrontFiles('shopify-theme')`), saved with `saveExport`. diff --git a/examples/plugins/ecommerce/icon.svg b/examples/plugins/ecommerce/icon.svg new file mode 100644 index 0000000..0ecad8e --- /dev/null +++ b/examples/plugins/ecommerce/icon.svg @@ -0,0 +1 @@ + diff --git a/examples/plugins/ecommerce/main.js b/examples/plugins/ecommerce/main.js new file mode 100644 index 0000000..e8f73c9 --- /dev/null +++ b/examples/plugins/ecommerce/main.js @@ -0,0 +1,293 @@ +/// + +// Ecommerce — a store for a storefront design, as a plugin: everything about Shopify lives here, none of it in the +// editor. Its Store tab (contributes.panels) connects a Shopify store, browses its real catalogue, fills the design +// with it, makes any design a store and checks what it still needs; its exporter (contributes.exporters) writes a +// Shopify theme. The editor keeps only what every store shares: the design's data, its forms, and Publish › Store, +// which deploys the design as a Next.js Commerce store with the site environment this plugin sets. +// +// Shopify's Storefront API answers any origin (CORS *), so the catalogue is read straight from the store with its +// public token — the token every visitor's browser uses on a headless store, which is why it may be kept in the file. + +const API_VERSION = '2025-07' +/** What the plugin keeps with the open file (popcraft.setFileData): the store this design sells from. */ +const STORE_KEY = 'store' + +// ─── Shopify ───────────────────────────────────────────────────────────────────────────────────────────────────── + +/** A store's address as Shopify names it, from what was pasted (a URL, an admin link, the bare name). */ +function shopDomain(input) { + const host = String(input || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '') + if (/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(host)) return host + if (/^[a-z0-9][a-z0-9-]*$/.test(host)) return `${host}.myshopify.com` + return null +} + +async function shopify(store, query, variables = {}) { + const res = await fetch(`https://${store.domain}/api/${API_VERSION}/graphql.json`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-shopify-storefront-access-token': store.token }, + body: JSON.stringify({ query, variables }), + }) + const body = await res.json().catch(() => ({})) + if (!res.ok || body.errors?.length) throw new Error(body.errors?.[0]?.message || (res.status === 401 || res.status === 403 ? 'Shopify refused the token' : `Shopify answered ${res.status}`)) + return body.data +} + +const PRODUCT = `id handle title description availableForSale tags + priceRange { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } } + featuredImage { url altText width height } + variants(first: 1) { nodes { id } }` + +/** Shopify's sort keys: a collection's products sort by CREATED, a search's by CREATED_AT. */ +const SORTS = { + newest: { products: 'CREATED_AT', collection: 'CREATED', reverse: true }, + 'price-asc': { products: 'PRICE', collection: 'PRICE', reverse: false }, + 'price-desc': { products: 'PRICE', collection: 'PRICE', reverse: true }, + title: { products: 'TITLE', collection: 'TITLE', reverse: false }, + 'best-selling': { products: 'BEST_SELLING', collection: 'BEST_SELLING', reverse: false }, +} + +/** + * The store's products for a query: one product by its handle, a category's (a Shopify collection's handle), or a + * search over the whole catalogue; sorted, at most `first`. + */ +async function products(store, { category = '', product = '', q = '', sort = 'newest', first = 24 }) { + const n = Math.max(1, Math.min(Number(first) || 24, 100)) + const s = SORTS[sort] || SORTS.newest + if (product) { + const d = await shopify(store, `query($h: String!) { product(handle: $h) { ${PRODUCT} } }`, { h: product }) + return d.product ? [d.product] : [] + } + if (category) { + const d = await shopify(store, `query($h: String!, $n: Int!, $k: ProductCollectionSortKeys, $r: Boolean) { + collection(handle: $h) { products(first: $n, sortKey: $k, reverse: $r) { nodes { ${PRODUCT} } } } }`, + { h: category, n, k: s.collection, r: s.reverse }) + const list = d.collection?.products.nodes ?? [] + // A category's products are searched here: Shopify's search spans the whole catalogue, not one collection. + return q ? list.filter(p => `${p.title} ${p.description} ${p.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase())) : list + } + const d = await shopify(store, `query($n: Int!, $k: ProductSortKeys, $r: Boolean, $q: String) { products(first: $n, sortKey: $k, reverse: $r, query: $q) { nodes { ${PRODUCT} } } }`, + { n, k: s.products, r: s.reverse, q: q || null }) + return d.products.nodes +} + +async function categories(store) { + const d = await shopify(store, '{ collections(first: 100, sortKey: TITLE) { nodes { handle title description } } }') + return d.collections.nodes.filter(c => c.handle !== 'frontpage') +} + +const money = m => (m ? new Intl.NumberFormat('en-US', { style: 'currency', currency: m.currencyCode }).format(Number(m.amount)) : '') + +/** A Shopify product as the design's product fields (commerce.product's keys). */ +const productValues = p => ({ + title: p.title, handle: p.handle, url: `/product/${p.handle}`, description: p.description, + price: money(p.priceRange.minVariantPrice), max_price: money(p.priceRange.maxVariantPrice), + image: p.featuredImage?.url ?? '', image_alt: p.featuredImage?.altText || p.title, + available: p.availableForSale, variant_id: p.variants.nodes[0]?.id ?? '', tags: p.tags.join(', '), +}) + +// ─── The design ────────────────────────────────────────────────────────────────────────────────────────────────── + +/** What a store's collections are called when this plugin makes them, and a few samples each starts with. */ +const STORE_COLLECTIONS = [ + { schema: 'commerce.product', name: 'Products', samples: [ + { title: 'Canvas tote', handle: 'canvas-tote', url: '/product/canvas-tote', description: 'Heavy waxed canvas with leather handles.', price: '$48.00', image_alt: 'Canvas tote', available: true, variant_id: 'gid://shopify/ProductVariant/1', tags: 'bags' }, + { title: 'Wool beanie', handle: 'wool-beanie', url: '/product/wool-beanie', description: 'Ribbed merino, one size.', price: '$32.00', image_alt: 'Wool beanie', available: true, variant_id: 'gid://shopify/ProductVariant/2', tags: 'hats' }, + { title: 'Field notebook', handle: 'field-notebook', url: '/product/field-notebook', description: 'Ninety-six dot-grid pages that lie flat.', price: '$14.00', image_alt: 'Field notebook', available: true, variant_id: 'gid://shopify/ProductVariant/3', tags: 'paper' }, + ] }, + { schema: 'commerce.collection', name: 'Categories', samples: [ + { title: 'Bags', handle: 'bags', url: '/search/bags', description: 'Totes, packs and pouches.' }, + { title: 'Hats', handle: 'hats', url: '/search/hats', description: 'For every season.' }, + ] }, + { schema: 'commerce.cartLine', name: 'Cart lines', samples: [ + { title: 'Canvas tote', variant: '', url: '/product/canvas-tote', quantity: 1, price: '$48.00', merchandise_id: 'gid://shopify/ProductVariant/1' }, + ] }, + { schema: 'commerce.cart', name: 'Cart', samples: [{ count: 1, subtotal: '$48.00', taxes: 'Calculated at checkout', total: '$48.00', checkout_url: '' }] }, + { schema: 'commerce.menu', name: 'Menu', samples: [{ title: 'Shop all', url: '/search' }, { title: 'Bags', url: '/search/bags' }] }, +] + +/** The design's collection that stands for `schema`, if it has one (getStorefront lists them with their schema). */ +async function collectionFor(schema) { + const store = await popcraft.getStorefront() + return store.collections.find(c => c.schema === schema) ?? null +} + +/** Replace a collection's records with `rows` (values by field key). */ +async function replaceRecords(collection, rows) { + const have = /** @type {{ records?: { id: string }[] }} */ (await popcraft.getRecords({ collection: collection.id })) + const ids = (have?.records ?? []).map(r => r.id) + await popcraft.editRecords({ collection: collection.id, remove: ids, upsert: rows.map(values => ({ values })) }) +} + +/** Make the store's collections the design is missing, each with samples (or the connected store's products). */ +async function makeStore(store) { + const made = [] + for (const c of STORE_COLLECTIONS) { + if (await collectionFor(c.schema)) continue + await popcraft.editCollection({ name: c.name, schema: c.schema }) + const col = await collectionFor(c.schema) + let rows = c.samples + if (store && c.schema === 'commerce.product') rows = (await products(store, { first: 6 })).map(productValues) + if (store && c.schema === 'commerce.collection') rows = (await categories(store)).slice(0, 6).map(x => ({ title: x.title, handle: x.handle, url: `/search/${x.handle}`, description: x.description })) + if (col && rows.length) await replaceRecords(col, rows) + made.push(c.name) + } + return made +} + +// ─── What the design still needs to sell ───────────────────────────────────────────────────────────────────────── + +const layerOf = id => String(id).split('~')[0] +/** Each storefront form in a page's HTML: its layer, what it does, and the names it sends. */ +function formsIn(html) { + const out = [] + const re = /]*)>([\s\S]*?)<\/form>/g + for (let m = re.exec(html); m; m = re.exec(html)) { + const action = /data-pc-action="(\w+)"/.exec(m[1])?.[1] + const id = /data-pc-id="([^"]+)"/.exec(m[1])?.[1] + if (!action || !id) continue + const names = [...m[2].matchAll(/\bname="([^"]+)"/g)].map(x => x[1]) + out.push({ action, id: layerOf(id), names }) + } + return out +} + +/** A form's hidden field: the value it sends, made a layer of the form that takes no room. */ +async function addHiddenField(formId, name, value) { + const id = await popcraft.createNode('TEXT', { parentId: formId, name: `${name} field`, characters: '', width: 1, height: 1, layoutPositioning: 'ABSOLUTE' }) + await popcraft.setControl({ ids: [id], kind: 'INPUT', inputType: 'hidden', name, value }) + return id +} + +/** + * The checklist: each thing a store needs, whether the design has it, the layer to look at, and the fix the plugin + * can make itself (`fix`). + */ +async function checklist() { + const design = await popcraft.getStorefront() + const env = await popcraft.getSiteEnvironment() + const has = schema => design.collections.some(c => c.schema === schema) + const lists = schema => design.pages.filter(p => p.html.includes(`schema="${schema}"`)) + const forms = design.pages.flatMap(p => formsIn(p.html).map(f => ({ ...f, page: p.name }))) + const items = [] + const add = (id, label, ok, more = {}) => items.push({ id, label, ok, ...more }) + add('store', 'Sells from a Shopify store', !!env.SHOPIFY_STORE_DOMAIN, { hint: 'Connect one above: Publish › Store deploys with it.' }) + add('products', 'A products collection', has('commerce.product'), { fix: has('commerce.product') ? undefined : 'make-store' }) + add('list', 'A list of products', lists('commerce.product').length > 0, { hint: 'Repeat a product card over Products (Repeat for each record).' }) + add('product-page', 'A page per product', design.pages.some(p => p.about === 'commerce.product'), { hint: 'Give the product screen a page per record of Products (Page address).' }) + const adds = forms.filter(f => f.action === 'addToCart') + add('add', 'Add to cart', adds.length > 0, { hint: 'Set a form inside a product to Sending it › Adds the product to the cart.' }) + for (const f of adds.filter(f => !f.names.includes('merchandiseId'))) add(`add-field-${f.id}`, `Add to cart on ${f.page} sends its product`, false, { layer: f.id, fix: 'field', field: ['merchandiseId', '{{item.variant_id}}'] }) + add('cart', 'A cart page', lists('commerce.cartLine').length > 0, { hint: 'Repeat a line over Cart lines on the cart screen.' }) + for (const f of forms.filter(f => (f.action === 'removeCartLine' || f.action === 'updateCartLine') && !f.names.includes('merchandiseId'))) add(`line-field-${f.id}`, `${f.action === 'removeCartLine' ? 'Remove' : 'Quantity'} on ${f.page} sends its line`, false, { layer: f.id, fix: 'field', field: ['merchandiseId', '{{item.merchandise_id}}'] }) + add('checkout', 'Checkout', forms.some(f => f.action === 'checkout'), { hint: 'Set a form on the cart to Sending it › Goes to checkout.' }) + return items +} + +// ─── The Store tab ─────────────────────────────────────────────────────────────────────────────────────────────── + +async function panel() { + /** @type {{ domain: string, token: string, name: string } | null} */ + let store = (await popcraft.getFileData(STORE_KEY)) || null + const say = (type, body) => popcraft.ui.postMessage({ type, ...body }) + const fail = (e, about) => say('error', { about, message: e instanceof Error ? e.message : String(e) }) + + popcraft.ui.onmessage = async msg => { + try { + switch (msg.type) { + case 'ready': + say('store', { store: store && { domain: store.domain, name: store.name } }) + if (store) say('categories', { categories: await categories(store) }) + say('checklist', { items: await checklist() }) + break + case 'connect': { + const domain = shopDomain(msg.domain) + if (!domain) throw new Error('That is not a store address: your-store.myshopify.com') + const token = String(msg.token || '').trim() + if (token.length < 16) throw new Error('Paste the public access token from Headless › your storefront › Storefront API') + const next = { domain, token } + const d = await shopify(next, '{ shop { name } }') + store = { ...next, name: d.shop.name } + await popcraft.setFileData(STORE_KEY, store) + // What Publish › Store deploys with: the store's address and public token, as the site environment. + await popcraft.setSiteEnvironment({ SHOPIFY_STORE_DOMAIN: domain, SHOPIFY_STOREFRONT_ACCESS_TOKEN: token }) + say('store', { store: { domain, name: store.name } }) + say('categories', { categories: await categories(store) }) + say('checklist', { items: await checklist() }) + await popcraft.notify(`Connected ${store.name}`) + break + } + case 'disconnect': + store = null + await popcraft.setFileData(STORE_KEY, null) + await popcraft.setSiteEnvironment({ SHOPIFY_STORE_DOMAIN: null, SHOPIFY_STOREFRONT_ACCESS_TOKEN: null }) + say('store', { store: null }) + say('checklist', { items: await checklist() }) + break + case 'query': + if (!store) throw new Error('Connect a store first') + say('products', { products: (await products(store, msg.query)).map(p => ({ ...productValues(p), width: p.featuredImage?.width, height: p.featuredImage?.height })), query: msg.query }) + break + case 'place': { + // A product's picture on the page, at the size Shopify's CDN serves it for. + const url = new URL(msg.product.image) + url.searchParams.set('width', '800') + const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()) + const w = 400, h = msg.product.width && msg.product.height ? Math.round(400 * msg.product.height / msg.product.width) : 400 + const id = await popcraft.createImage({ bytes: Array.from(bytes), mimeType: 'image/jpeg', name: msg.product.title, width: w, height: h }) + await popcraft.setSelection([id]) + break + } + case 'use': { + const col = await collectionFor('commerce.product') + if (!col) throw new Error('This design has no products collection: Make it a store first') + await replaceRecords(col, msg.products) + if (msg.categories && store) { + const cats = await collectionFor('commerce.collection') + if (cats) await replaceRecords(cats, (await categories(store)).slice(0, 12).map(x => ({ title: x.title, handle: x.handle, url: `/search/${x.handle}`, description: x.description }))) + } + await popcraft.notify(`The design shows ${msg.products.length} of your store's products`) + break + } + case 'make-store': { + const made = await makeStore(store) + await popcraft.notify(made.length ? `Made ${made.join(', ')}` : 'This design already has every store collection') + say('checklist', { items: await checklist() }) + break + } + case 'check': + say('checklist', { items: await checklist() }) + break + case 'select': + await popcraft.setSelection([msg.layer]) + break + case 'fix': { + if (msg.item.fix === 'make-store') await makeStore(store) + if (msg.item.fix === 'field') await popcraft.setSelection([await addHiddenField(msg.item.layer, ...msg.item.field)]) + say('checklist', { items: await checklist() }) + break + } + } + } catch (e) { fail(e, msg.type) } + } +} + +// ─── The exporter: a Shopify theme ─────────────────────────────────────────────────────────────────────────────── + +async function exportTheme() { + try { + const theme = await popcraft.getStorefrontFiles('shopify-theme') + await popcraft.saveExport({ name: theme.name, mimeType: 'application/zip', bytes: theme.bytes }) + await popcraft.notify(`Shopify theme: ${theme.files} files. Upload it in Online Store › Themes › Add theme`) + } catch (e) { + await popcraft.notify(e instanceof Error ? e.message : String(e), { error: true }) + } + popcraft.closePlugin() +} + +;(async () => { + const request = popcraft.exportRequest || (await popcraft.getExportRequest()) + if (request?.exporterId === 'shopify-theme') return exportTheme() + return panel() +})() diff --git a/examples/plugins/ecommerce/manifest.json b/examples/plugins/ecommerce/manifest.json new file mode 100644 index 0000000..bd35f39 --- /dev/null +++ b/examples/plugins/ecommerce/manifest.json @@ -0,0 +1,21 @@ +{ + "id": "popcraft.ecommerce", + "name": "Ecommerce", + "version": "1.0.0", + "description": "Design a store that sells. Connect a Shopify store and browse its real catalogue by category, product or search; place its pictures on the page or fill the design's products with them; make any design a store and see what it still needs; export a Shopify theme. Publish › Store deploys it as a Next.js Commerce store.", + "author": "PopCraft", + "main": "main.js", + "ui": "ui.html", + "permissions": ["read", "write", "export", "network"], + "contributes": { + "panels": [ + { "id": "store", "title": "Store", "icon": "icon.svg", "side": "right" } + ], + "exporters": [ + { "id": "shopify-theme", "label": "Shopify theme (.zip)", "extension": ".zip" } + ] + }, + "icon": "icon.svg", + "category": "data", + "tags": ["ecommerce", "shopify", "store", "shop", "storefront", "next.js commerce", "products", "catalogue", "theme"] +} diff --git a/examples/plugins/ecommerce/ui.html b/examples/plugins/ecommerce/ui.html new file mode 100644 index 0000000..efa20d1 --- /dev/null +++ b/examples/plugins/ecommerce/ui.html @@ -0,0 +1,142 @@ + + + + + + +

Store

+ +
+ + +

In Shopify: install the Headless sales channel, create a storefront, and copy its public Storefront API token. It is the token a store's visitors' browsers use, so it is kept with this file.

+ +
+ + + +

Set up the design

+ +

Adds the collections a store needs (products, categories, cart, cart lines, menu) with samples, or your store's products when connected.

+
What it still needs to sell
+
    + + + + +