tess_edit 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +225 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/tailwind/tess_edit/engine.css +10 -0
  6. data/app/controllers/tess_edit/application_controller.rb +5 -0
  7. data/app/controllers/tess_edit/compilations_controller.rb +10 -0
  8. data/app/helpers/tess_edit/application_helper.rb +4 -0
  9. data/app/javascript/tess_edit/blocks/builtin.js +26 -0
  10. data/app/javascript/tess_edit/blocks/button.js +32 -0
  11. data/app/javascript/tess_edit/blocks/column.js +24 -0
  12. data/app/javascript/tess_edit/blocks/condition.js +15 -0
  13. data/app/javascript/tess_edit/blocks/divider.js +24 -0
  14. data/app/javascript/tess_edit/blocks/fields.js +42 -0
  15. data/app/javascript/tess_edit/blocks/hero.js +60 -0
  16. data/app/javascript/tess_edit/blocks/image.js +29 -0
  17. data/app/javascript/tess_edit/blocks/loop.js +23 -0
  18. data/app/javascript/tess_edit/blocks/navbar.js +49 -0
  19. data/app/javascript/tess_edit/blocks/raw.js +18 -0
  20. data/app/javascript/tess_edit/blocks/root.js +83 -0
  21. data/app/javascript/tess_edit/blocks/section.js +40 -0
  22. data/app/javascript/tess_edit/blocks/social.js +39 -0
  23. data/app/javascript/tess_edit/blocks/spacer.js +10 -0
  24. data/app/javascript/tess_edit/blocks/text.js +39 -0
  25. data/app/javascript/tess_edit/blocks/wrapper.js +24 -0
  26. data/app/javascript/tess_edit/controllers/editor_controller.js +758 -0
  27. data/app/javascript/tess_edit/core/document.js +192 -0
  28. data/app/javascript/tess_edit/core/history.js +46 -0
  29. data/app/javascript/tess_edit/core/lock.js +36 -0
  30. data/app/javascript/tess_edit/core/media.js +54 -0
  31. data/app/javascript/tess_edit/core/registry.js +94 -0
  32. data/app/javascript/tess_edit/core/starter.js +89 -0
  33. data/app/javascript/tess_edit/export/compile.js +28 -0
  34. data/app/javascript/tess_edit/export/mjml.js +116 -0
  35. data/app/javascript/tess_edit/export/richtext.js +111 -0
  36. data/app/javascript/tess_edit/index.js +21 -0
  37. data/app/javascript/tess_edit/ui/blocks_panel.js +44 -0
  38. data/app/javascript/tess_edit/ui/canvas.js +437 -0
  39. data/app/javascript/tess_edit/ui/code_input.js +137 -0
  40. data/app/javascript/tess_edit/ui/dom.js +35 -0
  41. data/app/javascript/tess_edit/ui/icons.js +63 -0
  42. data/app/javascript/tess_edit/ui/inspector.js +377 -0
  43. data/app/javascript/tess_edit/ui/text_editing.js +124 -0
  44. data/app/javascript/tess_edit/ui/tree.js +138 -0
  45. data/config/importmap.rb +6 -0
  46. data/config/routes.rb +3 -0
  47. data/lib/tasks/tess_edit_tasks.rake +4 -0
  48. data/lib/tess_edit/engine.rb +18 -0
  49. data/lib/tess_edit/version.rb +3 -0
  50. data/lib/tess_edit.rb +28 -0
  51. data/vendor/javascript/tess_edit/tiptap.js +147 -0
  52. metadata +149 -0
@@ -0,0 +1,192 @@
1
+ // The document is a plain JSON tree:
2
+ // { id, type, attributes: {}, data: {}, children: [] }
3
+ // `attributes` map one-to-one onto MJML attributes, `data` holds everything else
4
+ // (rich text, Liquid expressions, link lists). All functions here are pure apart
5
+ // from the mutating helpers, which change the tree they are given in place.
6
+
7
+ import { getBlock, paletteItem, typesForRoles } from "tess_edit/core/registry"
8
+
9
+ export const VERSION = 1
10
+
11
+ export function uid() {
12
+ return "b" + Math.random().toString(36).slice(2, 9)
13
+ }
14
+
15
+ export function clone(value) {
16
+ return JSON.parse(JSON.stringify(value))
17
+ }
18
+
19
+ // Builds a node of `type` with the block's defaults; `overrides` are merged in.
20
+ export function createNode(type, overrides = {}) {
21
+ const definition = getBlock(type)
22
+ if (!definition) throw new Error(`Unknown block type: ${type}`)
23
+ const defaults = definition.defaults ? definition.defaults() : {}
24
+ return {
25
+ id: uid(),
26
+ type,
27
+ attributes: { ...(defaults.attributes || {}), ...(overrides.attributes || {}) },
28
+ data: { ...(defaults.data || {}), ...(overrides.data || {}) },
29
+ children: overrides.children || defaults.children || []
30
+ }
31
+ }
32
+
33
+ export function createDocument() {
34
+ return { version: VERSION, ...createNode("root") }
35
+ }
36
+
37
+ // Calls fn(node, parent, index, depth) for every node, depth first.
38
+ export function walk(node, fn, parent = null, index = 0, depth = 0) {
39
+ if (fn(node, parent, index, depth) === false) return
40
+ ;(node.children || []).forEach((child, i) => walk(child, fn, node, i, depth + 1))
41
+ }
42
+
43
+ export function find(doc, id) {
44
+ let result = null
45
+ walk(doc, (node, parent, index) => {
46
+ if (result) return false
47
+ if (node.id === id) {
48
+ result = { node, parent, index }
49
+ return false
50
+ }
51
+ })
52
+ return result
53
+ }
54
+
55
+ // Ancestors of `id`, nearest first (not including the node itself).
56
+ export function ancestors(doc, id) {
57
+ const path = []
58
+ const visit = (node, trail) => {
59
+ if (node.id === id) {
60
+ path.push(...trail.slice().reverse())
61
+ return true
62
+ }
63
+ return (node.children || []).some((child) => visit(child, [...trail, node]))
64
+ }
65
+ visit(doc, [])
66
+ return path
67
+ }
68
+
69
+ export function isDescendant(doc, ancestorId, id) {
70
+ return ancestors(doc, id).some((node) => node.id === ancestorId)
71
+ }
72
+
73
+ // The roles that may be placed directly inside the node with `parentId`.
74
+ // Loop and Condition ("inherit") are transparent: they accept what their
75
+ // nearest other ancestor accepts, except columns.
76
+ export function acceptedRoles(doc, parentId) {
77
+ const hit = find(doc, parentId)
78
+ if (!hit) return []
79
+ const accepts = getBlock(hit.node.type)?.accepts
80
+ if (accepts !== "inherit") return accepts || []
81
+ const owner = ancestors(doc, parentId).find((node) => getBlock(node.type)?.accepts !== "inherit")
82
+ return (owner ? getBlock(owner.type).accepts : getBlock("root").accepts).filter((role) => role !== "column")
83
+ }
84
+
85
+ // The block types that may be placed directly inside the node with `parentId`.
86
+ export function accepted(doc, parentId) {
87
+ return typesForRoles(acceptedRoles(doc, parentId))
88
+ }
89
+
90
+ export function canInsert(doc, parentId, type) {
91
+ return accepted(doc, parentId).includes(type)
92
+ }
93
+
94
+ // Whether children of `parentId` flow horizontally (columns in a section).
95
+ export function isHorizontal(doc, parentId) {
96
+ const hit = find(doc, parentId)
97
+ return hit ? !!getBlock(hit.node.type)?.horizontal : false
98
+ }
99
+
100
+ // Gives a node and all of its descendants fresh ids, for duplicating and pasting.
101
+ export function renumber(node) {
102
+ const copy = clone(node)
103
+ walk(copy, (n) => {
104
+ n.id = uid()
105
+ })
106
+ return copy
107
+ }
108
+
109
+ export function insert(doc, parentId, index, node) {
110
+ const hit = find(doc, parentId)
111
+ if (!hit) throw new Error(`No node ${parentId}`)
112
+ hit.node.children ||= []
113
+ const at = Math.max(0, Math.min(index ?? hit.node.children.length, hit.node.children.length))
114
+ hit.node.children.splice(at, 0, node)
115
+ return node
116
+ }
117
+
118
+ export function remove(doc, id) {
119
+ const hit = find(doc, id)
120
+ if (!hit || !hit.parent) return null
121
+ hit.parent.children.splice(hit.index, 1)
122
+ return hit
123
+ }
124
+
125
+ // Moves node `id` to position `index` in `parentId`. `index` is counted in the
126
+ // target's children as they are before the move.
127
+ export function move(doc, id, parentId, index) {
128
+ if (id === parentId || isDescendant(doc, id, parentId)) return false
129
+ const hit = find(doc, id)
130
+ if (!hit || !hit.parent) return false
131
+ let at = index
132
+ if (hit.parent.id === parentId && hit.index < index) at -= 1
133
+ hit.parent.children.splice(hit.index, 1)
134
+ insert(doc, parentId, at, hit.node)
135
+ return true
136
+ }
137
+
138
+ export function duplicate(doc, id) {
139
+ const hit = find(doc, id)
140
+ if (!hit || !hit.parent) return null
141
+ const copy = renumber(hit.node)
142
+ hit.parent.children.splice(hit.index + 1, 0, copy)
143
+ return copy
144
+ }
145
+
146
+ // Sets a value on a node. `key` is "attributes.padding" or "data.subject".
147
+ // An empty string removes an attribute, so MJML falls back to its default.
148
+ export function setValue(doc, id, key, value) {
149
+ const hit = find(doc, id)
150
+ if (!hit) return
151
+ const [scope, name] = key.split(".")
152
+ hit.node[scope] ||= {}
153
+ if (scope === "attributes" && (value === "" || value === null || value === undefined)) {
154
+ delete hit.node[scope][name]
155
+ } else {
156
+ hit.node[scope][name] = value
157
+ }
158
+ }
159
+
160
+ export function getValue(node, key) {
161
+ const [scope, name] = key.split(".")
162
+ return node[scope]?.[name]
163
+ }
164
+
165
+ // Light validation of a document read from storage or an import.
166
+ export function normalize(input) {
167
+ const doc = typeof input === "string" ? JSON.parse(input) : clone(input)
168
+ // The root block was called "page" before; such documents still load.
169
+ if (doc?.type === "page") doc.type = "root"
170
+ if (!doc || doc.type !== "root") throw new Error("Not a TessEdit document: the top node must be the root block")
171
+ walk(doc, (node) => {
172
+ if (!getBlock(node.type)) throw new Error(`Unknown block type: ${node.type}`)
173
+ node.id ||= uid()
174
+ node.attributes ||= {}
175
+ node.data ||= {}
176
+ node.children ||= []
177
+ })
178
+ doc.version ||= VERSION
179
+ return doc
180
+ }
181
+
182
+ // The node (or small tree) a Blocks panel tile creates.
183
+ export function createFromPalette(key) {
184
+ const item = paletteItem(key)
185
+ if (!item) throw new Error(`Unknown palette item: ${key}`)
186
+ return item.create({ createNode })
187
+ }
188
+
189
+ // The block type of the node a tile creates, used to check where it may go.
190
+ export function itemType(key) {
191
+ return paletteItem(key)?.type || null
192
+ }
@@ -0,0 +1,46 @@
1
+ // Undo/redo over whole-document snapshots. Changes with the same coalesce key
2
+ // within a second (typing in one field) become a single undo step.
3
+
4
+ export class History {
5
+ constructor(limit = 100) {
6
+ this.limit = limit
7
+ this.undoStack = []
8
+ this.redoStack = []
9
+ this.lastKey = null
10
+ this.lastTime = 0
11
+ }
12
+
13
+ record(snapshot, coalesceKey = null) {
14
+ const now = Date.now()
15
+ const coalesce = coalesceKey && coalesceKey === this.lastKey && now - this.lastTime < 1000
16
+ if (!coalesce) {
17
+ this.undoStack.push(snapshot)
18
+ if (this.undoStack.length > this.limit) this.undoStack.shift()
19
+ }
20
+ this.redoStack = []
21
+ this.lastKey = coalesceKey
22
+ this.lastTime = now
23
+ }
24
+
25
+ undo(current) {
26
+ if (!this.undoStack.length) return null
27
+ this.redoStack.push(current)
28
+ this.lastKey = null
29
+ return this.undoStack.pop()
30
+ }
31
+
32
+ redo(current) {
33
+ if (!this.redoStack.length) return null
34
+ this.undoStack.push(current)
35
+ this.lastKey = null
36
+ return this.redoStack.pop()
37
+ }
38
+
39
+ get canUndo() {
40
+ return this.undoStack.length > 0
41
+ }
42
+
43
+ get canRedo() {
44
+ return this.redoStack.length > 0
45
+ }
46
+ }
@@ -0,0 +1,36 @@
1
+ // Locking a document, for editing one part of a design that is otherwise
2
+ // fixed, such as a template's own content in its layout:
3
+ //
4
+ // { "except": ["content"], "fields": ["data.subject", "data.preheader"] }
5
+ //
6
+ // With a lock, only what is inside a block whose type is in `except` can
7
+ // change: those blocks take new children, and everything below them can be
8
+ // edited, moved, duplicated and deleted. The `except` blocks themselves stay
9
+ // where they are. Of the root, only the listed `fields` can change. Without a
10
+ // lock (null) everything can change.
11
+
12
+ import { ancestors, find } from "tess_edit/core/document"
13
+
14
+ export function parseLock(value) {
15
+ if (!value) return null
16
+ const lock = typeof value === "string" ? JSON.parse(value) : value
17
+ return { except: lock.except || [], fields: lock.fields || [] }
18
+ }
19
+
20
+ // Whether the node may be edited, moved, duplicated or deleted.
21
+ export function isFree(doc, lock, id) {
22
+ if (!lock) return true
23
+ return ancestors(doc, id).some((node) => lock.except.includes(node.type))
24
+ }
25
+
26
+ // Whether blocks may be added to, or moved into, the node.
27
+ export function canFill(doc, lock, parentId) {
28
+ if (!lock) return true
29
+ const hit = find(doc, parentId)
30
+ return !!hit && (lock.except.includes(hit.node.type) || isFree(doc, lock, parentId))
31
+ }
32
+
33
+ export function canEditField(doc, lock, id, key) {
34
+ if (!lock || isFree(doc, lock, id)) return true
35
+ return id === doc.id && lock.fields.includes(key)
36
+ }
@@ -0,0 +1,54 @@
1
+ // Images to choose from, kept by the host app. The editor's mediaUrl answers:
2
+ //
3
+ // GET → { images: [{ id, url, name }] }
4
+ // POST multipart/form-data, "file" → { id, url, name }, or { errors: [...] }
5
+ //
6
+ // `id` is the host's own reference to the image, `url` is where the email
7
+ // loads it from. An image field (type "image") stores both: the URL in its
8
+ // key, the id and where the image comes from in its `image` and `source` keys.
9
+
10
+ import { getValue } from "tess_edit/core/document"
11
+
12
+ function csrfHeaders() {
13
+ const token = document.querySelector("meta[name='csrf-token']")?.content
14
+ return token ? { "X-CSRF-Token": token } : {}
15
+ }
16
+
17
+ async function json(response, action) {
18
+ const result = (response.headers.get("Content-Type") || "").includes("json") ? await response.json() : null
19
+ if (!response.ok || !result) throw new Error(result?.errors?.join(", ") || `${action} failed (${response.status} ${response.statusText})`)
20
+ return result
21
+ }
22
+
23
+ export async function fetchMedia(url) {
24
+ const response = await fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
25
+ return (await json(response, "Loading the images")).images || []
26
+ }
27
+
28
+ export async function uploadMedia(url, file) {
29
+ const body = new FormData()
30
+ body.append("file", file)
31
+ const response = await fetch(url, { method: "POST", headers: { Accept: "application/json", ...csrfHeaders() }, body, credentials: "same-origin" })
32
+ return json(response, "The upload")
33
+ }
34
+
35
+ // The Image block's image field
36
+ export const IMAGE_FIELD = { key: "attributes.src", image: "data.image", source: "data.source" }
37
+
38
+ // An image field for an image attribute, e.g. a background:
39
+ // imageField("attributes.background-url", "Background image", "data.background")
40
+ export function imageField(key, label, image) {
41
+ return { key, label, type: "image", image, source: `${image}Source` }
42
+ }
43
+
44
+ // The image fields of a block definition
45
+ export function imageFields(definition) {
46
+ return (definition?.fields || []).flatMap((group) => group.fields || []).filter((field) => field.type === "image")
47
+ }
48
+
49
+ // Where a field's image comes from: "upload" (one of the host's images) or
50
+ // "web" (any URL). Without a mediaUrl there are only web images.
51
+ export function imageSource(node, editor, field = IMAGE_FIELD) {
52
+ if (!editor?.media?.enabled) return "web"
53
+ return getValue(node, field.source) || (getValue(node, field.image) ? "upload" : "web")
54
+ }
@@ -0,0 +1,94 @@
1
+ // The block registry. Every block, built-in or added by a host app, is one
2
+ // definition registered here:
3
+ //
4
+ // registerBlock({
5
+ // type: "product", // stored in the document JSON
6
+ // name: "Product", // shown in Layers, Inspect and on the canvas
7
+ // icon: "image", // a built-in icon name, or SVG markup for a 24×24 viewBox
8
+ // roles: ["content"], // where it may be placed, see below
9
+ // accepts: undefined, // roles it takes as children, "inherit", or undefined for a leaf
10
+ // defaults: () => ({ attributes: {}, data: {} }),
11
+ // fields: [{ title: "Product", fields: [{ key: "data.sku", label: "SKU", type: "text" }] }],
12
+ // // a field's visible: (node, editor) => boolean shows it only when it applies;
13
+ // // imageField(key, label, idKey) (core/media.js) makes an image field;
14
+ // // a "code" field takes language: "html" (default) or "css"
15
+ // hint: "Shown at the top of Inspect.",
16
+ // label: (node) => `Product · ${node.data.sku}`, // optional, for Layers and the canvas
17
+ // richText: "data.content", // optional: a Tiptap JSON value edited on the canvas by double-click
18
+ // palette: [{ key, label, icon, category, create: ({ createNode }) => node }], // optional
19
+ // render: { mjml: (node, ctx) => "<mj-text …>" } // ctx: see export/mjml.js
20
+ // })
21
+ //
22
+ // Roles: "body" (inside the page or a wrapper: sections, heroes), "wrapper",
23
+ // "column" (inside a section) and "content" (inside a column or hero). A
24
+ // container accepts the roles in its `accepts`; Loop and Condition use
25
+ // "inherit" and accept what their nearest other container accepts.
26
+ //
27
+ // Without a `palette`, a block with a `category` gets one tile in the Blocks
28
+ // panel; `palette: []` gives it none. Registering a type again replaces it.
29
+
30
+ import { builtinBlocks, builtinCategories } from "tess_edit/blocks/builtin"
31
+
32
+ const definitions = new Map()
33
+ const categoryList = new Map()
34
+
35
+ export function registerCategory({ key, label, order = 100 }) {
36
+ if (!key || !label) throw new Error("A category needs a key and a label")
37
+ categoryList.set(key, { key, label, order })
38
+ }
39
+
40
+ export function registerBlock(definition) {
41
+ const { type, name, render } = definition || {}
42
+ if (!type || typeof type !== "string") throw new Error("A block needs a type")
43
+ if (!name) throw new Error(`Block ${type} needs a name`)
44
+ if (typeof render?.mjml !== "function") throw new Error(`Block ${type} needs render.mjml(node, ctx)`)
45
+ if (type !== "root" && !(definition.roles || []).length) throw new Error(`Block ${type} needs roles, e.g. ["content"]`)
46
+
47
+ const palette = (definition.palette ?? (definition.category ? [{ key: type, label: name, icon: definition.icon, category: definition.category }] : [])).map((item) => ({
48
+ icon: definition.icon,
49
+ category: definition.category,
50
+ create: ({ createNode }) => createNode(type),
51
+ ...item,
52
+ type
53
+ }))
54
+ definitions.set(type, { roles: [], ...definition, palette })
55
+ return definitions.get(type)
56
+ }
57
+
58
+ export function getBlock(type) {
59
+ return definitions.get(type)
60
+ }
61
+
62
+ export function blockTypes() {
63
+ return [...definitions.keys()]
64
+ }
65
+
66
+ export function isContainer(type) {
67
+ return !!getBlock(type)?.accepts
68
+ }
69
+
70
+ // The block types that may go where the given roles are accepted.
71
+ export function typesForRoles(roles) {
72
+ return [...definitions.values()].filter((definition) => definition.roles.some((role) => roles.includes(role))).map((definition) => definition.type)
73
+ }
74
+
75
+ export function categories() {
76
+ return [...categoryList.values()].sort((a, b) => a.order - b.order)
77
+ }
78
+
79
+ // Tiles for the Blocks panel, in registration order.
80
+ export function paletteItems() {
81
+ return [...definitions.values()].flatMap((definition) => definition.palette)
82
+ }
83
+
84
+ export function paletteItem(key) {
85
+ return paletteItems().find((item) => item.key === key)
86
+ }
87
+
88
+ export function label(node) {
89
+ const definition = getBlock(node.type)
90
+ return definition?.label?.(node) || definition?.name || node.type
91
+ }
92
+
93
+ builtinCategories.forEach(registerCategory)
94
+ builtinBlocks.forEach(registerBlock)
@@ -0,0 +1,89 @@
1
+ // A sample document for new, empty editors and the demo page.
2
+
3
+ import { createDocument, createNode } from "tess_edit/core/document"
4
+
5
+ const paragraph = (...content) => ({ type: "paragraph", content })
6
+ const text = (value, marks) => (marks ? { type: "text", text: value, marks } : { type: "text", text: value })
7
+ const doc = (...content) => ({ type: "doc", content })
8
+
9
+ const section = (attributes, ...columns) => createNode("section", { attributes, children: columns })
10
+ const column = (attributes, ...children) => createNode("column", { attributes, children })
11
+
12
+ export function starterDocument() {
13
+ const page = createDocument()
14
+ page.data.subject = "Your order {{ order.number }}"
15
+ page.data.preheader = "Thanks for shopping with us, {{ customer.first_name }}."
16
+
17
+ page.children = [
18
+ section(
19
+ { padding: "24px 0px 8px" },
20
+ column(
21
+ {},
22
+ createNode("text", {
23
+ attributes: { align: "center", "font-size": "20px", "font-weight": "700", "letter-spacing": "1px" },
24
+ data: { content: doc(paragraph(text("ACME STORE"))) }
25
+ })
26
+ )
27
+ ),
28
+ section(
29
+ { "background-color": "#ffffff", padding: "32px 0px", "border-radius": "8px" },
30
+ column(
31
+ {},
32
+ createNode("text", {
33
+ attributes: { "font-size": "26px", "font-weight": "700", "line-height": "1.3" },
34
+ data: { content: doc(paragraph(text("Thanks for your order, {{ customer.first_name }}!"))) }
35
+ }),
36
+ createNode("text", {
37
+ data: {
38
+ content: doc(
39
+ paragraph(text("We've received order "), text("{{ order.number }}", [{ type: "bold" }]), text(" and will let you know when it ships.")),
40
+ paragraph(),
41
+ paragraph(text("Here's what you ordered:"))
42
+ )
43
+ }
44
+ }),
45
+ createNode("loop", {
46
+ data: { variable: "line", collection: "order.lines" },
47
+ children: [
48
+ createNode("text", {
49
+ attributes: { padding: "4px 25px" },
50
+ data: { content: doc(paragraph(text("{{ line.quantity }} × {{ line.name }} — {{ line.total | money }}"))) }
51
+ })
52
+ ]
53
+ }),
54
+ createNode("divider", { attributes: { padding: "16px 25px" } }),
55
+ createNode("button", { attributes: { href: "{{ order.url }}" }, data: { content: "View your order" } })
56
+ )
57
+ ),
58
+ createNode("condition", {
59
+ data: { expression: "customer.orders_count == 1" },
60
+ children: [
61
+ section(
62
+ { padding: "24px 0px 0px" },
63
+ column(
64
+ { width: "50%" },
65
+ createNode("image", { attributes: { src: "https://placehold.co/600x400/dbeafe/1e3a8a.png?text=Welcome", padding: "0px 12px 0px 25px", "border-radius": "6px" } })
66
+ ),
67
+ column(
68
+ { width: "50%", "vertical-align": "middle" },
69
+ createNode("text", {
70
+ data: { content: doc(paragraph(text("Welcome!", [{ type: "bold" }])), paragraph(text("This is your first order. Here's 10% off the next one with code WELCOME10."))) }
71
+ })
72
+ )
73
+ )
74
+ ]
75
+ }),
76
+ section(
77
+ { padding: "24px 0px" },
78
+ column(
79
+ {},
80
+ createNode("social"),
81
+ createNode("text", {
82
+ attributes: { align: "center", "font-size": "12px", color: "#6b7280" },
83
+ data: { content: doc(paragraph(text("ACME Store · Main Street 1 · Amsterdam")), paragraph(text("Unsubscribe", [{ type: "link", attrs: { href: "{{ unsubscribe_url }}" } }]))) }
84
+ })
85
+ )
86
+ )
87
+ ]
88
+ return page
89
+ }
@@ -0,0 +1,28 @@
1
+ // MJML → HTML on the server. The editor posts the MJML to the engine's compile
2
+ // endpoint, which compiles it with MRML (TessEdit.compile in Ruby), so the
3
+ // canvas shows the same HTML the host app sends.
4
+ //
5
+ // Resolves to { html, errors }: html is null when the MJML could not be
6
+ // compiled or the request failed, with the reasons in errors. Resolves to null
7
+ // when the request was aborted through options.signal.
8
+
9
+ export const COMPILE_URL = "/tess_edit/compile"
10
+
11
+ export async function compile(mjml, { url = COMPILE_URL, signal } = {}) {
12
+ const token = document.querySelector("meta[name='csrf-token']")?.content
13
+ try {
14
+ const response = await fetch(url, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json", Accept: "application/json", ...(token ? { "X-CSRF-Token": token } : {}) },
17
+ body: JSON.stringify({ mjml }),
18
+ credentials: "same-origin",
19
+ signal
20
+ })
21
+ const result = (response.headers.get("Content-Type") || "").includes("json") ? await response.json() : null
22
+ if (!result) return { html: null, errors: [`The compile request failed (${response.status} ${response.statusText})`] }
23
+ return { html: result.html ?? null, errors: result.errors || [] }
24
+ } catch (error) {
25
+ if (error.name === "AbortError") return null
26
+ return { html: null, errors: [`The compile request failed: ${error.message}`] }
27
+ }
28
+ }
@@ -0,0 +1,116 @@
1
+ // JSON document → MJML. Each block renders itself through its render.mjml(node,
2
+ // ctx) hook (see blocks/*.js); this file holds the shared helpers in `ctx`.
3
+ //
4
+ // With { editor: true } the output is meant for the editing canvas: every
5
+ // element gets a css-class "tess-<id>" so clicks map back to blocks, empty
6
+ // containers get a placeholder ("tessph-<id>"), and Liquid tags are left out so
7
+ // loops and conditions show their content once.
8
+ //
9
+ // Without it, Loop and Condition blocks emit their Liquid tags inside <mj-raw>,
10
+ // which MJML copies into the HTML unchanged, so Liquid can run on the compiled HTML.
11
+ //
12
+ // The ctx a block's render.mjml gets:
13
+ // editor true when rendering for the canvas
14
+ // context where the node sits: "body" (mj-body, mj-wrapper),
15
+ // "section" (mj-section) or "column" (mj-column, mj-hero)
16
+ // attrs(a, extra) ` name="value"` pairs, empty values left out
17
+ // cssClass(node) { "css-class": "tess-<id> <the block's own css-class>" }
18
+ // in the editor, {} otherwise
19
+ // element(tag, node, inner?) <tag> with the node's attributes and class;
20
+ // self-closing without inner
21
+ // container(tag, node) <tag> around the node's rendered children
22
+ // children(node, context?) the children's MJML, as an array
23
+ // liquid(node, open, close) children wrapped in Liquid tags (not in the editor)
24
+ // indent(text, level), escapeText, escapeAttribute, richText(content)
25
+
26
+ import { getBlock } from "tess_edit/core/registry"
27
+ import { escapeAttribute, escapeText, richTextToHtml } from "tess_edit/export/richtext"
28
+
29
+ const PLACEHOLDER_COLOR = "#94a3b8"
30
+
31
+ function attrs(attributes = {}, extra = {}) {
32
+ return Object.entries({ ...attributes, ...extra })
33
+ .filter(([, value]) => value !== undefined && value !== null && value !== "")
34
+ .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
35
+ .join("")
36
+ }
37
+
38
+ function indent(text, level) {
39
+ const pad = " ".repeat(level)
40
+ return text
41
+ .split("\n")
42
+ .map((line) => (line ? pad + line : line))
43
+ .join("\n")
44
+ }
45
+
46
+ // What a container's children render as, from the roles it accepts.
47
+ function childContext(definition, context) {
48
+ const accepts = definition?.accepts
49
+ if (!Array.isArray(accepts)) return context
50
+ if (accepts.includes("column")) return "section"
51
+ if (accepts.includes("content")) return "column"
52
+ return "body"
53
+ }
54
+
55
+ class Renderer {
56
+ constructor(options) {
57
+ this.editor = !!options.editor
58
+ }
59
+
60
+ // The marker class goes first, before the block's own css-class, so the
61
+ // canvas finds blocks by the start of the class attribute.
62
+ cssClass(node) {
63
+ if (!this.editor) return {}
64
+ return { "css-class": [`tess-${node.id}`, node.attributes?.["css-class"]].filter(Boolean).join(" ") }
65
+ }
66
+
67
+ children(node, context) {
68
+ const kids = node.children || []
69
+ if (!kids.length && this.editor) return [this.placeholder(node, context)]
70
+ return kids.map((child) => this.node(child, context))
71
+ }
72
+
73
+ placeholder(node, context) {
74
+ const text = context === "body" ? "Drop a section here" : context === "section" ? "Drop a column here" : "Drop content here"
75
+ const mjText = `<mj-text css-class="tessph-${node.id}" align="center" color="${PLACEHOLDER_COLOR}" font-size="13px" padding="18px 25px">${text}</mj-text>`
76
+ if (context === "column") return mjText
77
+ if (context === "section") return `<mj-column css-class="tessph-${node.id}">\n ${mjText}\n</mj-column>`
78
+ return `<mj-section css-class="tessph-${node.id}">\n <mj-column>\n ${mjText}\n </mj-column>\n</mj-section>`
79
+ }
80
+
81
+ context(context) {
82
+ return {
83
+ editor: this.editor,
84
+ context,
85
+ attrs,
86
+ indent,
87
+ escapeText,
88
+ escapeAttribute,
89
+ richText: richTextToHtml,
90
+ cssClass: (node) => this.cssClass(node),
91
+ children: (node, childCtx) => this.children(node, childCtx ?? childContext(getBlock(node.type), context)),
92
+ element: (tag, node, inner) => {
93
+ const open = `<${tag}${attrs(node.attributes, this.cssClass(node))}`
94
+ return inner === undefined ? `${open} />` : `${open}>${inner}</${tag}>`
95
+ },
96
+ container: (tag, node) => {
97
+ const inner = this.children(node, childContext(getBlock(node.type), context)).join("\n")
98
+ return `<${tag}${attrs(node.attributes, this.cssClass(node))}>\n${indent(inner, 1)}\n</${tag}>`
99
+ },
100
+ liquid: (node, open, close) => {
101
+ const inner = this.children(node, context)
102
+ if (this.editor) return inner.join("\n")
103
+ return [`<mj-raw>${open}</mj-raw>`, ...inner, `<mj-raw>${close}</mj-raw>`].join("\n")
104
+ }
105
+ }
106
+ }
107
+
108
+ node(node, context) {
109
+ const render = getBlock(node.type)?.render?.mjml
110
+ return render ? render(node, this.context(context)) : ""
111
+ }
112
+ }
113
+
114
+ export function toMjml(doc, options = {}) {
115
+ return new Renderer(options).node(doc, null)
116
+ }