@samva/markup 0.8.0 → 0.10.0
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.
- package/AGENTS.md +107 -46
- package/CHANGELOG.md +345 -0
- package/README.md +188 -52
- package/dist/{brand-DcoVkh_S.d.ts → assets-BIsyMQ92.d.ts} +41 -11
- package/dist/{email/brand.js → brand-C0MKTwmX.js} +64 -4
- package/dist/{theme-css.d.ts → brand.d.ts} +12 -3
- package/dist/brand.js +4 -0
- package/dist/channel-specs-C2feJwuP.d.ts +13 -0
- package/dist/compiler.d.ts +88 -115
- package/dist/compiler.js +750 -171
- package/dist/components-BZCqegS8.js +1864 -0
- package/dist/diagnostic-model-DJtqbETd.js +38 -0
- package/dist/{diagnostics-OcQKQYSY.d.ts → diagnostic-model-DxthUZBT.d.ts} +3 -21
- package/dist/{diagnostics-BMYtBCQk.d.ts → diagnostics-B-Gym34R.d.ts} +8 -4
- package/dist/{email/diagnostics.js → diagnostics-CnKFefKz.js} +4 -3
- package/dist/diagnostics.d.ts +410 -2
- package/dist/diagnostics.js +402 -52
- package/dist/edit.d.ts +234 -0
- package/dist/edit.js +947 -0
- package/dist/email/jsx-dev-runtime.d.ts +2 -2
- package/dist/email/jsx-dev-runtime.js +1 -2
- package/dist/email/jsx-runtime.d.ts +1 -1
- package/dist/email/jsx-runtime.js +1 -85
- package/dist/{email/components.d.ts → email.d.ts} +22 -11
- package/dist/email.js +2 -0
- package/dist/fmt.d.ts +32 -0
- package/dist/fmt.js +174 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/{render-vAv_4r9v.js → ir-CLhlklKV.js} +96 -279
- package/dist/jsx-runtime-CBlexoeR.js +98 -0
- package/dist/{elements-BzjjnoCS.d.ts → jsx-runtime-ylIOnaSf.d.ts} +76 -76
- package/dist/profile-pWGtWZXF.js +3803 -0
- package/dist/render-BPonDpCl.d.ts +539 -0
- package/dist/render-BQmfh94k.js +257 -0
- package/dist/render.d.ts +35 -0
- package/dist/render.js +49 -0
- package/dist/sms.d.ts +16 -0
- package/dist/sms.js +12 -0
- package/dist/source-locations-C8wCsiAy.d.ts +23 -0
- package/dist/template-DMPwxMSq.d.ts +65 -0
- package/dist/template-zLUjcwfF.js +42 -0
- package/dist/{theme-css-hFqcPSar.js → theme-css-JtRCm1fb.js} +2 -2
- package/dist/whatsapp.d.ts +57 -0
- package/dist/whatsapp.js +22 -0
- package/generated/contract.json +1788 -0
- package/generated/reference.md +360 -0
- package/package.json +46 -239
- package/samva-brand.d.ts +4 -4
- package/skills/sml/SKILL.md +123 -0
- package/dist/ast-BrDtApwS.d.ts +0 -107
- package/dist/ast.d.ts +0 -2
- package/dist/ast.js +0 -124
- package/dist/candidate.d.ts +0 -29
- package/dist/candidate.js +0 -88
- package/dist/catalog-CKuOooB8.d.ts +0 -354
- package/dist/checks-D73Xizbw.js +0 -59366
- package/dist/checks.d.ts +0 -33
- package/dist/checks.js +0 -2
- package/dist/classes.d.ts +0 -26
- package/dist/classes.js +0 -184
- package/dist/compile-sms.d.ts +0 -28
- package/dist/compile-sms.js +0 -44
- package/dist/compile-whatsapp.d.ts +0 -56
- package/dist/compile-whatsapp.js +0 -186
- package/dist/compile.d.ts +0 -18
- package/dist/compile.js +0 -29
- package/dist/components.d.ts +0 -289
- package/dist/components.js +0 -106
- package/dist/conditionals.d.ts +0 -10
- package/dist/conditionals.js +0 -74
- package/dist/corpus-CPPfgAHN.d.ts +0 -106
- package/dist/css-CS2jLID6.d.ts +0 -109
- package/dist/email/assets.d.ts +0 -40
- package/dist/email/assets.js +0 -63
- package/dist/email/brand-footer.d.ts +0 -34
- package/dist/email/brand-footer.js +0 -62
- package/dist/email/brand.d.ts +0 -2
- package/dist/email/components.js +0 -240
- package/dist/email/diagnostics.d.ts +0 -2
- package/dist/email/elements.d.ts +0 -2
- package/dist/email/elements.js +0 -299
- package/dist/email/html.d.ts +0 -2
- package/dist/email/html.js +0 -278
- package/dist/email/render.d.ts +0 -50
- package/dist/email/render.js +0 -3
- package/dist/email/selection.d.ts +0 -2
- package/dist/email/selection.js +0 -91
- package/dist/email/source-edit.d.ts +0 -67
- package/dist/email/source-edit.js +0 -185
- package/dist/email/stylesheets.d.ts +0 -15
- package/dist/email/stylesheets.js +0 -48
- package/dist/email/text.d.ts +0 -7
- package/dist/email/text.js +0 -100
- package/dist/emit.d.ts +0 -51
- package/dist/emit.js +0 -151
- package/dist/html-Bl9BAdqc.d.ts +0 -42
- package/dist/interpolate.d.ts +0 -24
- package/dist/interpolate.js +0 -43
- package/dist/jsx-dev-runtime.d.ts +0 -11
- package/dist/jsx-dev-runtime.js +0 -15
- package/dist/jsx-runtime-I-1nCtl2.d.ts +0 -57
- package/dist/jsx-runtime.d.ts +0 -2
- package/dist/jsx-runtime.js +0 -121
- package/dist/lexer.d.ts +0 -60
- package/dist/lexer.js +0 -281
- package/dist/model.d.ts +0 -24
- package/dist/model.js +0 -77
- package/dist/ops-BvwqHl7V.js +0 -630
- package/dist/ops.d.ts +0 -114
- package/dist/ops.js +0 -2
- package/dist/parser-t29cHLVL.d.ts +0 -396
- package/dist/parser.d.ts +0 -2
- package/dist/parser.js +0 -859
- package/dist/predicate-BI6uRRM-.js +0 -549
- package/dist/predicate-M-05Nd7R.d.ts +0 -56
- package/dist/predicate.d.ts +0 -2
- package/dist/predicate.js +0 -2
- package/dist/printer.d.ts +0 -6
- package/dist/printer.js +0 -122
- package/dist/registry-Dcp6QEK_.d.ts +0 -73
- package/dist/registry.d.ts +0 -2
- package/dist/registry.js +0 -646
- package/dist/render-template.d.ts +0 -45
- package/dist/render-template.js +0 -76
- package/dist/selection-CCArx9lU.d.ts +0 -91
- package/dist/showcase-egkusQBf.d.ts +0 -415
- package/dist/sms-segments-y4FpPilJ.d.ts +0 -13
- package/dist/sms-segments.d.ts +0 -2
- package/dist/sms-segments.js +0 -46
- package/dist/source-locations-DrCM82Ym.d.ts +0 -67
- package/dist/source-locations.d.ts +0 -2
- package/dist/source-locations.js +0 -102
- package/dist/starters/category-mapping.d.ts +0 -13
- package/dist/starters/category-mapping.js +0 -49
- package/dist/starters/corpus.d.ts +0 -2
- package/dist/starters/corpus.js +0 -194
- package/dist/starters/library.d.ts +0 -18
- package/dist/starters/library.js +0 -692
- package/dist/starters/public-gallery.d.ts +0 -266
- package/dist/starters/public-gallery.js +0 -916
- package/dist/starters/public-handoff.d.ts +0 -164
- package/dist/starters/public-handoff.js +0 -361
- package/dist/starters/public-preview-artifacts.d.ts +0 -43
- package/dist/starters/public-preview-artifacts.js +0 -104
- package/dist/starters/public-preview.d.ts +0 -61
- package/dist/starters/public-preview.js +0 -83
- package/dist/starters/showcase.d.ts +0 -2
- package/dist/starters/showcase.js +0 -335
- package/dist/starters/sources.d.ts +0 -45
- package/dist/starters/sources.js +0 -7762
- package/dist/starters/theme.d.ts +0 -45
- package/dist/starters/theme.js +0 -77
- package/dist/starters.d.ts +0 -2
- package/dist/starters.js +0 -874
- package/dist/tailwind-BYq7Lo6u.js +0 -727
- package/dist/template-DWftqh4x.d.ts +0 -60
- package/dist/template.d.ts +0 -2
- package/dist/template.js +0 -37
- package/dist/theme-css.js +0 -2
- package/dist/tsx-source-instrumentation.d.ts +0 -30
- package/dist/tsx-source-instrumentation.js +0 -115
- package/dist/tsx-tracer.d.ts +0 -112
- package/dist/tsx-tracer.js +0 -985
- package/dist/variables-J_L3qxRa.d.ts +0 -77
- package/dist/variables.d.ts +0 -2
- package/dist/variables.js +0 -141
- package/dist/version-BITr58kk.js +0 -96
- package/generated/sml-agent-contract.json +0 -709
- package/generated/sml-email-agent-contract.json +0 -615
- package/generated/sml-email-language-reference.md +0 -94
- package/generated/sml-language-reference.md +0 -195
package/dist/edit.js
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import { n as isObject } from "./guards-eV1qLbf2.js";
|
|
2
|
+
import { d as completeJsxSourceLocation, u as JSX_SOURCE_LOCATION_PROP } from "./jsx-runtime-CBlexoeR.js";
|
|
3
|
+
import { t as buildLineMap } from "./diagnostic-model-DJtqbETd.js";
|
|
4
|
+
import { c as WHATSAPP_HEADER_TYPES, n as introducedProfileErrors, o as SMS_CATEGORIES, s as WHATSAPP_CATEGORIES, t as checkStaticProfile } from "./profile-pWGtWZXF.js";
|
|
5
|
+
import { parse } from "@babel/parser";
|
|
6
|
+
//#region src/source-edit.ts
|
|
7
|
+
const node = (value) => isObject(value) && "type" in value && typeof value.type === "string" && "start" in value && typeof value.start === "number" && "end" in value && typeof value.end === "number";
|
|
8
|
+
const children = (value) => Array.isArray(value) ? value.filter(node) : [];
|
|
9
|
+
const walk$1 = (value, visit, parent = null) => {
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
for (const item of value) walk$1(item, visit, parent);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!node(value)) return;
|
|
15
|
+
visit(value, parent);
|
|
16
|
+
for (const [key, child] of Object.entries(value)) if (key !== "loc" && key !== "extra" && key !== "comments") walk$1(child, visit, value);
|
|
17
|
+
};
|
|
18
|
+
const parsed = (source) => {
|
|
19
|
+
try {
|
|
20
|
+
const result = parse(source, {
|
|
21
|
+
sourceType: "module",
|
|
22
|
+
plugins: ["typescript", "jsx"]
|
|
23
|
+
});
|
|
24
|
+
return node(result) ? result : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const binding = (value) => value.type === "Identifier" || value.type === "MemberExpression" && value.computed === false && node(value.object) && binding(value.object);
|
|
30
|
+
/** JSX trims each line of text and joins the non-empty lines with one space. */
|
|
31
|
+
const collapseJsxText = (value) => value.split(/\r\n|\n|\r|\u2028|\u2029/).map((line) => line.trim()).filter((line) => line !== "").join(" ");
|
|
32
|
+
const property = (name, value, source, syntax) => {
|
|
33
|
+
const expression = value.type === "JSXExpressionContainer" && node(value.expression) ? value.expression : value;
|
|
34
|
+
const literal = expression.type === "StringLiteral" || expression.type === "NumericLiteral" || expression.type === "BooleanLiteral" || expression.type === "JSXText";
|
|
35
|
+
const raw = source.slice(expression.start, expression.end);
|
|
36
|
+
const inset = expression.type === "JSXText" ? {
|
|
37
|
+
start: expression.start + (raw.length - raw.trimStart().length),
|
|
38
|
+
end: expression.end - (raw.length - raw.trimEnd().length)
|
|
39
|
+
} : {
|
|
40
|
+
start: value.start,
|
|
41
|
+
end: value.end
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
literalType: expression.type === "NumericLiteral" ? "number" : expression.type === "BooleanLiteral" ? "boolean" : "string",
|
|
46
|
+
kind: literal ? "literal" : binding(expression) ? "binding" : "computed",
|
|
47
|
+
value: literal && expression.type === "JSXText" && typeof expression.value === "string" ? collapseJsxText(expression.value) : literal && (typeof expression.value === "string" || typeof expression.value === "number" || typeof expression.value === "boolean") ? String(expression.value) : source.slice(expression.start, expression.end),
|
|
48
|
+
start: inset.start,
|
|
49
|
+
end: inset.end,
|
|
50
|
+
syntax
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/** The JSX elements directly before and after `element` in its parent's child list. */
|
|
54
|
+
const siblingsOf = (element, parent, source) => {
|
|
55
|
+
if (parent?.type !== "JSXElement" && parent?.type !== "JSXFragment") return {
|
|
56
|
+
previous: null,
|
|
57
|
+
next: null
|
|
58
|
+
};
|
|
59
|
+
const list = children(parent.children).filter((child) => child.type !== "JSXText" || source.slice(child.start, child.end).trim() !== "");
|
|
60
|
+
const index = list.findIndex((child) => child.start === element.start);
|
|
61
|
+
const range = (child) => child?.type === "JSXElement" ? {
|
|
62
|
+
start: child.start,
|
|
63
|
+
end: child.end
|
|
64
|
+
} : null;
|
|
65
|
+
return {
|
|
66
|
+
previous: range(list[index - 1]),
|
|
67
|
+
next: range(list[index + 1])
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/** Resolve a renderer origin against the unchanged authored entry, never against HTML offsets. */
|
|
71
|
+
const inspectSourceElement = (source, file, origin) => {
|
|
72
|
+
if (file !== origin.fileName) return null;
|
|
73
|
+
const tree = parsed(source);
|
|
74
|
+
if (tree === null) return null;
|
|
75
|
+
const lines = buildLineMap(source);
|
|
76
|
+
const lineStart = lines[origin.lineNumber - 1];
|
|
77
|
+
if (lineStart === void 0 || !Number.isInteger(origin.columnNumber) || origin.columnNumber < 1) return null;
|
|
78
|
+
const offset = lineStart + origin.columnNumber - 1;
|
|
79
|
+
if (offset >= (lines[origin.lineNumber] ?? source.length)) return null;
|
|
80
|
+
let found = null;
|
|
81
|
+
walk$1(tree, (element, parent) => {
|
|
82
|
+
if (element.type !== "JSXElement" || element.start !== offset || !node(element.openingElement)) return;
|
|
83
|
+
const opening = element.openingElement;
|
|
84
|
+
const properties = [];
|
|
85
|
+
for (const attribute of children(opening.attributes)) if (attribute.type === "JSXAttribute" && node(attribute.name) && typeof attribute.name.name === "string" && node(attribute.value)) {
|
|
86
|
+
const value = attribute.value;
|
|
87
|
+
const expression = value.type === "JSXExpressionContainer" && node(value.expression) ? value.expression : null;
|
|
88
|
+
if (attribute.name.name === "style" && expression?.type === "ObjectExpression" && !children(expression.properties).some((member) => member.type === "SpreadElement")) for (const member of children(expression.properties)) {
|
|
89
|
+
if (member.type !== "ObjectProperty" || member.computed === true || !node(member.key) || !node(member.value)) continue;
|
|
90
|
+
const key = member.key.type === "Identifier" ? member.key.name : member.key.value;
|
|
91
|
+
if (typeof key === "string") properties.push(property(`style.${key}`, member.value, source, "value"));
|
|
92
|
+
}
|
|
93
|
+
else properties.push(property(attribute.name.name, value, source, "attribute"));
|
|
94
|
+
}
|
|
95
|
+
const meaningful = children(element.children).filter((child) => child.type !== "JSXText" || source.slice(child.start, child.end).trim() !== "");
|
|
96
|
+
if (meaningful.length === 1 && meaningful[0] !== void 0 && meaningful[0].type !== "JSXElement" && meaningful[0].type !== "JSXFragment") properties.unshift(property("children", meaningful[0], source, "child"));
|
|
97
|
+
else if (meaningful.length > 1) {
|
|
98
|
+
const texts = meaningful.flatMap((child, index) => child.type === "JSXText" ? [property(`text.${index}`, child, source, "child")] : []);
|
|
99
|
+
properties.unshift(...texts);
|
|
100
|
+
}
|
|
101
|
+
found = {
|
|
102
|
+
start: element.start,
|
|
103
|
+
end: element.end,
|
|
104
|
+
contentStart: opening.selfClosing === true ? null : opening.end,
|
|
105
|
+
contentEnd: node(element.closingElement) ? element.closingElement.start : null,
|
|
106
|
+
tag: node(opening.name) ? source.slice(opening.name.start, opening.name.end) : "element",
|
|
107
|
+
properties,
|
|
108
|
+
attributeInsertion: (() => {
|
|
109
|
+
const last = children(opening.attributes).at(-1);
|
|
110
|
+
if (last !== void 0) return last.end;
|
|
111
|
+
return node(opening.name) ? opening.name.end : opening.end - 1;
|
|
112
|
+
})(),
|
|
113
|
+
hasSpread: children(opening.attributes).some((attribute) => attribute.type === "JSXSpreadAttribute"),
|
|
114
|
+
structural: parent?.type === "JSXElement" || parent?.type === "JSXFragment",
|
|
115
|
+
...siblingsOf(element, parent, source)
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
return found;
|
|
119
|
+
};
|
|
120
|
+
const PLAIN_JSX_TEXT = /^[^\s{}<>&](?:[^{}<>&\r\n\u2028\u2029]*[^\s{}<>&])?$/;
|
|
121
|
+
/** Literal controls cannot overwrite a binding or computed expression. */
|
|
122
|
+
const literalReplacement = (source, field, value) => {
|
|
123
|
+
if (field.kind !== "literal") return null;
|
|
124
|
+
if (field.literalType === "number" && (value.trim() === "" || !Number.isFinite(Number(value)))) return null;
|
|
125
|
+
if (field.literalType === "boolean" && value !== "true" && value !== "false") return null;
|
|
126
|
+
const literal = field.literalType === "string" ? JSON.stringify(value) : field.literalType === "number" ? String(Number(value)) : value;
|
|
127
|
+
const before = source.slice(field.start, field.end);
|
|
128
|
+
const plainText = field.syntax === "child" && !before.startsWith("{") && PLAIN_JSX_TEXT.test(value);
|
|
129
|
+
return {
|
|
130
|
+
start: field.start,
|
|
131
|
+
end: field.end,
|
|
132
|
+
before,
|
|
133
|
+
after: plainText ? value : field.syntax === "child" || field.syntax === "attribute" && field.literalType !== "string" ? `{${literal}}` : literal
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
/** Explicit expression changes are proposals: callers show before/after and require Apply. */
|
|
137
|
+
const expressionReplacement = (source, field, expression) => ({
|
|
138
|
+
start: field.start,
|
|
139
|
+
end: field.end,
|
|
140
|
+
before: source.slice(field.start, field.end),
|
|
141
|
+
after: field.syntax === "value" ? expression : `{${expression}}`
|
|
142
|
+
});
|
|
143
|
+
const profileRefusal = (source, result, guard) => {
|
|
144
|
+
const file = guard.file ?? guard.entry;
|
|
145
|
+
const project = (content) => ({
|
|
146
|
+
...guard.files,
|
|
147
|
+
[file]: content
|
|
148
|
+
});
|
|
149
|
+
const introduced = introducedProfileErrors(checkStaticProfile(project(source), guard.entry), checkStaticProfile(project(result), guard.entry));
|
|
150
|
+
const first = introduced[0];
|
|
151
|
+
if (first === void 0) return null;
|
|
152
|
+
const more = introduced.length > 1 ? ` (and ${introduced.length - 1} more)` : "";
|
|
153
|
+
return `This change leaves the static profile: ${first.message}${first.fix === void 0 ? "" : ` ${first.fix}`}${more}`;
|
|
154
|
+
};
|
|
155
|
+
/** Apply exact, nonoverlapping edits and refuse syntax damage without changing the original. */
|
|
156
|
+
const applySourceReplacements = (source, edits, options = {}) => {
|
|
157
|
+
const sorted = [...edits].sort((a, b) => b.start - a.start);
|
|
158
|
+
let boundary = source.length;
|
|
159
|
+
let result = source;
|
|
160
|
+
for (const edit of sorted) {
|
|
161
|
+
if (edit.start < 0 || edit.end < edit.start || edit.end > boundary || source.slice(edit.start, edit.end) !== edit.before) return {
|
|
162
|
+
ok: false,
|
|
163
|
+
reason: "Source changed at this target. Select it again before applying the edit."
|
|
164
|
+
};
|
|
165
|
+
result = result.slice(0, edit.start) + edit.after + result.slice(edit.end);
|
|
166
|
+
boundary = edit.start;
|
|
167
|
+
}
|
|
168
|
+
if (parsed(result) === null) return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: "This change is not valid TSX. Review it in Source."
|
|
171
|
+
};
|
|
172
|
+
if (options.profile !== void 0 && result !== source) {
|
|
173
|
+
const refusal = profileRefusal(source, result, options.profile);
|
|
174
|
+
if (refusal !== null) return {
|
|
175
|
+
ok: false,
|
|
176
|
+
reason: refusal
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
source: result
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
const indentation = (source, offset) => {
|
|
185
|
+
const lineStart = Math.max(source.lastIndexOf("\n", offset - 1), source.lastIndexOf("\r", offset - 1));
|
|
186
|
+
const lead = source.slice(lineStart + 1, offset);
|
|
187
|
+
return /^[ \t]*$/.test(lead) ? lead : null;
|
|
188
|
+
};
|
|
189
|
+
/** The whole lines an element alone occupies, so removing it leaves no blank line behind. */
|
|
190
|
+
const ownLines = (source, element) => {
|
|
191
|
+
if (indentation(source, element.start) === null) return element;
|
|
192
|
+
const trailing = /^[ \t]*(\r\n|\n|\r)/.exec(source.slice(element.end));
|
|
193
|
+
if (trailing === null) return element;
|
|
194
|
+
return {
|
|
195
|
+
start: element.start - (indentation(source, element.start) ?? "").length,
|
|
196
|
+
end: element.end + trailing[0].length
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
/** Structural edits name a whole authored block; loop iterations are never source nodes. */
|
|
200
|
+
const structuralReplacements = (source, element, operation) => {
|
|
201
|
+
if (operation.kind === "insert") {
|
|
202
|
+
if (element.contentEnd === null) return null;
|
|
203
|
+
return [{
|
|
204
|
+
start: element.contentEnd,
|
|
205
|
+
end: element.contentEnd,
|
|
206
|
+
before: "",
|
|
207
|
+
after: operation.tsx
|
|
208
|
+
}];
|
|
209
|
+
}
|
|
210
|
+
if (!element.structural) return null;
|
|
211
|
+
const text = source.slice(element.start, element.end);
|
|
212
|
+
if (operation.kind === "duplicate") {
|
|
213
|
+
const indent = indentation(source, element.start);
|
|
214
|
+
return [{
|
|
215
|
+
start: element.end,
|
|
216
|
+
end: element.end,
|
|
217
|
+
before: "",
|
|
218
|
+
after: indent === null ? text : `\n${indent}${text}`
|
|
219
|
+
}];
|
|
220
|
+
}
|
|
221
|
+
if (operation.kind === "reorder") {
|
|
222
|
+
const neighbour = operation.direction === "before" ? element.previous : element.next;
|
|
223
|
+
if (neighbour === null) return null;
|
|
224
|
+
return [{
|
|
225
|
+
start: neighbour.start,
|
|
226
|
+
end: neighbour.end,
|
|
227
|
+
before: source.slice(neighbour.start, neighbour.end),
|
|
228
|
+
after: text
|
|
229
|
+
}, {
|
|
230
|
+
start: element.start,
|
|
231
|
+
end: element.end,
|
|
232
|
+
before: text,
|
|
233
|
+
after: source.slice(neighbour.start, neighbour.end)
|
|
234
|
+
}];
|
|
235
|
+
}
|
|
236
|
+
const removal = ownLines(source, element);
|
|
237
|
+
const removed = {
|
|
238
|
+
start: removal.start,
|
|
239
|
+
end: removal.end,
|
|
240
|
+
before: source.slice(removal.start, removal.end),
|
|
241
|
+
after: ""
|
|
242
|
+
};
|
|
243
|
+
if (operation.kind === "delete") return [removed];
|
|
244
|
+
const destination = operation.destination.contentEnd;
|
|
245
|
+
if (destination === null || destination >= element.start && destination <= element.end) return null;
|
|
246
|
+
return [removed, {
|
|
247
|
+
start: destination,
|
|
248
|
+
end: destination,
|
|
249
|
+
before: "",
|
|
250
|
+
after: text
|
|
251
|
+
}];
|
|
252
|
+
};
|
|
253
|
+
/** The object literal a top-level `const name = { ... }` holds. */
|
|
254
|
+
const topLevelObject = (tree, name) => {
|
|
255
|
+
const program = tree.program;
|
|
256
|
+
if (!node(program)) return null;
|
|
257
|
+
for (const item of children(program.body)) {
|
|
258
|
+
const declaration = item.type === "ExportNamedDeclaration" && node(item.declaration) ? item.declaration : item;
|
|
259
|
+
if (declaration.type !== "VariableDeclaration") continue;
|
|
260
|
+
for (const declarator of children(declaration.declarations)) if (node(declarator.id) && declarator.id.name === name && node(declarator.init) && declarator.init.type === "ObjectExpression") return declarator.init;
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
};
|
|
264
|
+
/** Declared fixture values are separate source targets from body bindings and literals. */
|
|
265
|
+
const inspectSourceFixtures = (source) => {
|
|
266
|
+
const tree = parsed(source);
|
|
267
|
+
if (tree === null) return [];
|
|
268
|
+
const found = [];
|
|
269
|
+
walk$1(tree, (call) => {
|
|
270
|
+
if (call.type !== "CallExpression" || !node(call.callee) || call.callee.type !== "Identifier" || call.callee.name !== "defineTemplate") return;
|
|
271
|
+
const definition = children(call.arguments)[0];
|
|
272
|
+
if (definition?.type !== "ObjectExpression") return;
|
|
273
|
+
const fixtures = children(definition.properties).find((field) => field.type === "ObjectProperty" && node(field.key) && field.key.name === "fixtures" && field.computed !== true);
|
|
274
|
+
if (fixtures === void 0 || !node(fixtures.value) || fixtures.value.type !== "ObjectExpression") return;
|
|
275
|
+
for (const field of children(fixtures.value.properties)) {
|
|
276
|
+
if (field.type !== "ObjectProperty" || field.computed === true || !node(field.key) || !node(field.value)) continue;
|
|
277
|
+
const name = field.key.type === "Identifier" ? field.key.name : field.key.value;
|
|
278
|
+
const value = field.value.type === "Identifier" && typeof field.value.name === "string" ? topLevelObject(tree, field.value.name) ?? field.value : field.value;
|
|
279
|
+
if (typeof name === "string") found.push({
|
|
280
|
+
name,
|
|
281
|
+
start: value.start,
|
|
282
|
+
end: value.end,
|
|
283
|
+
source: source.slice(value.start, value.end)
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return found;
|
|
288
|
+
};
|
|
289
|
+
const tokens = (value) => value.split(/\s+/).filter((item) => item !== "");
|
|
290
|
+
/**
|
|
291
|
+
* Edit an element's class list where it is written. A literal `className` is replaced, extended or
|
|
292
|
+
* trimmed in place; a missing one is added; one that is a condition or any other expression is
|
|
293
|
+
* refused, because rewriting it would discard the choice the author encoded.
|
|
294
|
+
*/
|
|
295
|
+
const classNameEdit = (source, element, operation) => {
|
|
296
|
+
const field = element.properties.find((item) => item.name === "className");
|
|
297
|
+
const next = (current) => {
|
|
298
|
+
if (operation.kind === "set") return tokens(operation.value).join(" ");
|
|
299
|
+
const wanted = tokens(operation.tokens);
|
|
300
|
+
return operation.kind === "add" ? [...current, ...wanted.filter((item) => !current.includes(item))].join(" ") : current.filter((item) => !wanted.includes(item)).join(" ");
|
|
301
|
+
};
|
|
302
|
+
if (field === void 0) {
|
|
303
|
+
if (element.hasSpread) return {
|
|
304
|
+
ok: false,
|
|
305
|
+
reason: "A spread attribute can supply className here, so a class list cannot be added safely. Edit it in Source."
|
|
306
|
+
};
|
|
307
|
+
if (operation.kind === "remove") return {
|
|
308
|
+
ok: true,
|
|
309
|
+
edits: []
|
|
310
|
+
};
|
|
311
|
+
const value = next([]);
|
|
312
|
+
if (value === "") return {
|
|
313
|
+
ok: true,
|
|
314
|
+
edits: []
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
edits: [{
|
|
319
|
+
start: element.attributeInsertion,
|
|
320
|
+
end: element.attributeInsertion,
|
|
321
|
+
before: "",
|
|
322
|
+
after: ` className=${JSON.stringify(value)}`
|
|
323
|
+
}]
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (field.kind !== "literal" || field.literalType !== "string") return {
|
|
327
|
+
ok: false,
|
|
328
|
+
reason: `className is ${field.kind === "binding" ? "bound to" : "computed from"} \`${field.value}\`, so editing it would discard that choice. Change the literal classes inside it in Source.`
|
|
329
|
+
};
|
|
330
|
+
const value = next(tokens(field.value));
|
|
331
|
+
return {
|
|
332
|
+
ok: true,
|
|
333
|
+
edits: [{
|
|
334
|
+
start: field.start,
|
|
335
|
+
end: field.end,
|
|
336
|
+
before: source.slice(field.start, field.end),
|
|
337
|
+
after: JSON.stringify(value)
|
|
338
|
+
}]
|
|
339
|
+
};
|
|
340
|
+
};
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/channel-edit.ts
|
|
343
|
+
const isNode = (value) => isObject(value) && typeof value.type === "string" && typeof value.start === "number" && typeof value.end === "number";
|
|
344
|
+
const nodeList = (value) => Array.isArray(value) ? value.filter(isNode) : [];
|
|
345
|
+
const nodeOf = (parent, key) => {
|
|
346
|
+
const value = parent[key];
|
|
347
|
+
return isNode(value) ? value : null;
|
|
348
|
+
};
|
|
349
|
+
const parseSource = (source) => {
|
|
350
|
+
try {
|
|
351
|
+
const result = parse(source, {
|
|
352
|
+
sourceType: "module",
|
|
353
|
+
plugins: ["typescript", "jsx"]
|
|
354
|
+
});
|
|
355
|
+
return isNode(result) ? result : null;
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const walk = (value, visit) => {
|
|
361
|
+
if (Array.isArray(value)) {
|
|
362
|
+
for (const item of value) walk(item, visit);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (!isNode(value)) return;
|
|
366
|
+
if (visit(value) === false) return;
|
|
367
|
+
for (const [key, child] of Object.entries(value)) if (key !== "loc" && key !== "extra" && key !== "comments") walk(child, visit);
|
|
368
|
+
};
|
|
369
|
+
const jsxName = (name) => {
|
|
370
|
+
if (name === null) return "";
|
|
371
|
+
if (name.type === "JSXIdentifier") return typeof name.name === "string" ? name.name : "";
|
|
372
|
+
if (name.type === "JSXMemberExpression") return `${jsxName(nodeOf(name, "object"))}.${jsxName(nodeOf(name, "property"))}`;
|
|
373
|
+
return "";
|
|
374
|
+
};
|
|
375
|
+
const elementOf = (node) => {
|
|
376
|
+
if (node.type !== "JSXElement") return null;
|
|
377
|
+
const opening = nodeOf(node, "openingElement");
|
|
378
|
+
if (opening === null) return null;
|
|
379
|
+
const closing = nodeOf(node, "closingElement");
|
|
380
|
+
return {
|
|
381
|
+
node,
|
|
382
|
+
opening,
|
|
383
|
+
closing,
|
|
384
|
+
tag: jsxName(nodeOf(opening, "name")),
|
|
385
|
+
children: nodeList(node.children),
|
|
386
|
+
attributes: nodeList(opening.attributes),
|
|
387
|
+
selfClosing: opening.selfClosing === true,
|
|
388
|
+
contentStart: opening.end,
|
|
389
|
+
contentEnd: closing === null ? opening.end : closing.start
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
const returnedElement = (fn) => {
|
|
393
|
+
const body = nodeOf(fn, "body");
|
|
394
|
+
if (body === null) return null;
|
|
395
|
+
if (body.type === "JSXElement") return body;
|
|
396
|
+
if (body.type !== "BlockStatement") return null;
|
|
397
|
+
const statements = nodeList(body.body).filter((statement) => statement.type !== "EmptyStatement");
|
|
398
|
+
const only = statements.length === 1 ? statements[0] : void 0;
|
|
399
|
+
const argument = only?.type === "ReturnStatement" && only !== void 0 ? nodeOf(only, "argument") : null;
|
|
400
|
+
return argument !== null && argument.type === "JSXElement" ? argument : null;
|
|
401
|
+
};
|
|
402
|
+
const propertyName = (property) => {
|
|
403
|
+
if (property.computed === true) return null;
|
|
404
|
+
const key = nodeOf(property, "key");
|
|
405
|
+
if (key === null) return null;
|
|
406
|
+
if (key.type === "Identifier") return typeof key.name === "string" ? key.name : null;
|
|
407
|
+
return key.type === "StringLiteral" && typeof key.value === "string" ? key.value : null;
|
|
408
|
+
};
|
|
409
|
+
/** The function behind `defineTemplate({ [channel]: … })`, when it is written in place. */
|
|
410
|
+
const channelFunction = (tree, channel) => {
|
|
411
|
+
let found = null;
|
|
412
|
+
walk(tree, (call) => {
|
|
413
|
+
if (found !== null) return false;
|
|
414
|
+
if (call.type !== "CallExpression") return;
|
|
415
|
+
const callee = nodeOf(call, "callee");
|
|
416
|
+
if (callee?.type !== "Identifier" || callee.name !== "defineTemplate") return;
|
|
417
|
+
const definition = nodeList(call.arguments)[0];
|
|
418
|
+
if (definition?.type !== "ObjectExpression") return false;
|
|
419
|
+
for (const property of nodeList(definition.properties)) {
|
|
420
|
+
if (propertyName(property) !== channel) continue;
|
|
421
|
+
const fn = property.type === "ObjectMethod" ? property : property.type === "ObjectProperty" ? nodeOf(property, "value") : null;
|
|
422
|
+
if (fn === null || fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression" && fn.type !== "ObjectMethod") return false;
|
|
423
|
+
const returned = returnedElement(fn);
|
|
424
|
+
const root = returned === null ? null : elementOf(returned);
|
|
425
|
+
if (root !== null) found = {
|
|
426
|
+
root,
|
|
427
|
+
parameter: nodeList(fn.params)[0] ?? null
|
|
428
|
+
};
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
});
|
|
433
|
+
return found;
|
|
434
|
+
};
|
|
435
|
+
/** The name an import binds a component to, or the component's own name when nothing is imported. */
|
|
436
|
+
const importedAs = (tree, specifier, imported) => {
|
|
437
|
+
let local = imported;
|
|
438
|
+
walk(tree, (node) => {
|
|
439
|
+
if (node.type !== "ImportDeclaration") return;
|
|
440
|
+
if (nodeOf(node, "source")?.value !== specifier) return false;
|
|
441
|
+
for (const item of nodeList(node.specifiers)) {
|
|
442
|
+
const name = nodeOf(item, "imported");
|
|
443
|
+
const alias = nodeOf(item, "local");
|
|
444
|
+
if (item.type === "ImportSpecifier" && (name?.name === imported || name?.value === imported)) local = typeof alias?.name === "string" ? alias.name : imported;
|
|
445
|
+
}
|
|
446
|
+
return false;
|
|
447
|
+
});
|
|
448
|
+
return local;
|
|
449
|
+
};
|
|
450
|
+
/** JSX text as the compiler reads it: lines are trimmed and joined, blank lines dropped. */
|
|
451
|
+
const jsxTextValue = (value) => {
|
|
452
|
+
const lines = value.split(/\r\n|\n|\r/);
|
|
453
|
+
let lastNonEmpty = 0;
|
|
454
|
+
lines.forEach((line, index) => {
|
|
455
|
+
if (/[^ \t]/.test(line)) lastNonEmpty = index;
|
|
456
|
+
});
|
|
457
|
+
let result = "";
|
|
458
|
+
lines.forEach((line, index) => {
|
|
459
|
+
let trimmed = line.replace(/\t/g, " ");
|
|
460
|
+
if (index !== 0) trimmed = trimmed.replace(/^ +/, "");
|
|
461
|
+
if (index !== lines.length - 1) trimmed = trimmed.replace(/ +$/, "");
|
|
462
|
+
if (trimmed === "") return;
|
|
463
|
+
result += index === lastNonEmpty ? trimmed : `${trimmed} `;
|
|
464
|
+
});
|
|
465
|
+
return result;
|
|
466
|
+
};
|
|
467
|
+
/** The value of a container that holds only a string, or null for any other expression. */
|
|
468
|
+
const stringContainerValue = (container) => {
|
|
469
|
+
const expression = nodeOf(container, "expression");
|
|
470
|
+
if (expression === null) return null;
|
|
471
|
+
if (expression.type === "StringLiteral" && typeof expression.value === "string") return expression.value;
|
|
472
|
+
if (expression.type === "TemplateLiteral" && nodeList(expression.expressions).length === 0) {
|
|
473
|
+
const cooked = nodeList(expression.quasis)[0]?.value;
|
|
474
|
+
return isObject(cooked) && typeof cooked.cooked === "string" ? cooked.cooked : null;
|
|
475
|
+
}
|
|
476
|
+
return null;
|
|
477
|
+
};
|
|
478
|
+
const summarize = (source, node) => {
|
|
479
|
+
const text = source.slice(node.start, node.end).replace(/\s+/g, " ").trim();
|
|
480
|
+
return text.length > 60 ? `${text.slice(0, 59)}…` : text;
|
|
481
|
+
};
|
|
482
|
+
/**
|
|
483
|
+
* The editable text and locked expressions of an element's children, in order. Text is a segment
|
|
484
|
+
* between locked children, so a text segment exists (possibly empty) before, between and after
|
|
485
|
+
* them.
|
|
486
|
+
*/
|
|
487
|
+
const segmentsOf = (source, element) => {
|
|
488
|
+
const segments = [];
|
|
489
|
+
let start = element.contentStart;
|
|
490
|
+
let text = "";
|
|
491
|
+
const flush = (end) => {
|
|
492
|
+
segments.push({
|
|
493
|
+
kind: "text",
|
|
494
|
+
text,
|
|
495
|
+
start,
|
|
496
|
+
end
|
|
497
|
+
});
|
|
498
|
+
text = "";
|
|
499
|
+
};
|
|
500
|
+
for (const child of element.children) {
|
|
501
|
+
if (child.type === "JSXText") {
|
|
502
|
+
text += jsxTextValue(typeof child.value === "string" ? child.value : "");
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (child.type === "JSXExpressionContainer") {
|
|
506
|
+
const value = stringContainerValue(child);
|
|
507
|
+
if (value !== null) {
|
|
508
|
+
text += value;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
flush(child.start);
|
|
513
|
+
segments.push({
|
|
514
|
+
kind: "locked",
|
|
515
|
+
summary: summarize(source, child),
|
|
516
|
+
start: child.start,
|
|
517
|
+
end: child.end
|
|
518
|
+
});
|
|
519
|
+
start = child.end;
|
|
520
|
+
}
|
|
521
|
+
flush(element.contentEnd);
|
|
522
|
+
return segments;
|
|
523
|
+
};
|
|
524
|
+
const isSafeRun = (char) => !/[{}<>&\r\n\t]/.test(char);
|
|
525
|
+
/**
|
|
526
|
+
* JSX for the given text. Characters JSX text cannot carry, and whitespace at an edge the
|
|
527
|
+
* compiler would trim, are written as string expressions; the rest stays plain text.
|
|
528
|
+
*/
|
|
529
|
+
const encodeText = (text, trimStart, trimEnd) => {
|
|
530
|
+
const chars = [...text];
|
|
531
|
+
const unsafe = chars.map((char) => !isSafeRun(char));
|
|
532
|
+
if (trimStart) for (let i = 0; i < chars.length && /\s/.test(chars[i]); i++) unsafe[i] = true;
|
|
533
|
+
if (trimEnd) for (let i = chars.length - 1; i >= 0 && /\s/.test(chars[i]); i--) unsafe[i] = true;
|
|
534
|
+
let result = "";
|
|
535
|
+
let run = "";
|
|
536
|
+
let runUnsafe = false;
|
|
537
|
+
const flush = () => {
|
|
538
|
+
if (run === "") return;
|
|
539
|
+
result += runUnsafe ? `{${JSON.stringify(run)}}` : run;
|
|
540
|
+
run = "";
|
|
541
|
+
};
|
|
542
|
+
chars.forEach((char, index) => {
|
|
543
|
+
if (run !== "" && unsafe[index] !== runUnsafe) flush();
|
|
544
|
+
runUnsafe = unsafe[index];
|
|
545
|
+
run += char;
|
|
546
|
+
});
|
|
547
|
+
flush();
|
|
548
|
+
return result;
|
|
549
|
+
};
|
|
550
|
+
/** Write `text` over the gap at `segment`, keeping the newlines and indentation around it. */
|
|
551
|
+
const writeText = (source, element, segments, index, text) => {
|
|
552
|
+
const segment = segments[index];
|
|
553
|
+
if (segment?.kind !== "text") return {
|
|
554
|
+
ok: false,
|
|
555
|
+
reason: "That part of the body is locked."
|
|
556
|
+
};
|
|
557
|
+
if (segment.text === text) return {
|
|
558
|
+
ok: true,
|
|
559
|
+
edits: []
|
|
560
|
+
};
|
|
561
|
+
if (element.selfClosing) {
|
|
562
|
+
const opening = element.opening;
|
|
563
|
+
let start = opening.end - 2;
|
|
564
|
+
while (start > opening.start && /\s/.test(source[start - 1])) start--;
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
edits: [{
|
|
568
|
+
start,
|
|
569
|
+
end: opening.end,
|
|
570
|
+
before: source.slice(start, opening.end),
|
|
571
|
+
after: `>${encodeText(text, false, false)}</${element.tag}>`
|
|
572
|
+
}]
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
const raw = source.slice(segment.start, segment.end);
|
|
576
|
+
const leading = /^\s*/.exec(raw)?.[0] ?? "";
|
|
577
|
+
const prefix = /[\r\n]/.test(leading) ? leading : "";
|
|
578
|
+
const rest = raw.slice(prefix.length);
|
|
579
|
+
const trailing = /\s*$/.exec(rest)?.[0] ?? "";
|
|
580
|
+
const suffix = /[\r\n]/.test(trailing) ? trailing : "";
|
|
581
|
+
const start = segment.start + prefix.length;
|
|
582
|
+
const end = segment.end - suffix.length;
|
|
583
|
+
return {
|
|
584
|
+
ok: true,
|
|
585
|
+
edits: [{
|
|
586
|
+
start,
|
|
587
|
+
end,
|
|
588
|
+
before: source.slice(start, end),
|
|
589
|
+
after: encodeText(text, prefix !== "", suffix !== "")
|
|
590
|
+
}]
|
|
591
|
+
};
|
|
592
|
+
};
|
|
593
|
+
const attributeNamed = (element, name) => element.attributes.find((attribute) => attribute.type === "JSXAttribute" && jsxName(nodeOf(attribute, "name")) === name) ?? null;
|
|
594
|
+
const attributeState = (source, element, name) => {
|
|
595
|
+
const attribute = attributeNamed(element, name);
|
|
596
|
+
if (attribute === null) return { kind: "absent" };
|
|
597
|
+
const value = nodeOf(attribute, "value");
|
|
598
|
+
if (value?.type === "StringLiteral" && typeof value.value === "string") return {
|
|
599
|
+
kind: "literal",
|
|
600
|
+
value: value.value
|
|
601
|
+
};
|
|
602
|
+
if (value?.type === "JSXExpressionContainer") {
|
|
603
|
+
const literal = stringContainerValue(value);
|
|
604
|
+
if (literal !== null) return {
|
|
605
|
+
kind: "literal",
|
|
606
|
+
value: literal
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
kind: "expression",
|
|
611
|
+
source: summarize(source, value ?? attribute)
|
|
612
|
+
};
|
|
613
|
+
};
|
|
614
|
+
const encodeAttribute = (value) => /["&\\\r\n]/.test(value) ? `{${JSON.stringify(value)}}` : `"${value}"`;
|
|
615
|
+
const replacement = (source, start, end, after) => ({
|
|
616
|
+
start,
|
|
617
|
+
end,
|
|
618
|
+
before: source.slice(start, end),
|
|
619
|
+
after
|
|
620
|
+
});
|
|
621
|
+
/** Set, add or remove one string attribute of an element. */
|
|
622
|
+
const setAttribute = (source, element, name, value) => {
|
|
623
|
+
const attribute = attributeNamed(element, name);
|
|
624
|
+
if (attribute === null) {
|
|
625
|
+
if (value === void 0) return {
|
|
626
|
+
ok: true,
|
|
627
|
+
edits: []
|
|
628
|
+
};
|
|
629
|
+
const anchor = element.attributes.at(-1)?.end ?? nodeOf(element.opening, "name")?.end ?? element.opening.end;
|
|
630
|
+
return {
|
|
631
|
+
ok: true,
|
|
632
|
+
edits: [replacement(source, anchor, anchor, ` ${name}=${encodeAttribute(value)}`)]
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
if (attributeState(source, element, name).kind === "expression") return {
|
|
636
|
+
ok: false,
|
|
637
|
+
reason: `\`${name}\` reads the input. Edit it in Source.`
|
|
638
|
+
};
|
|
639
|
+
if (value === void 0) {
|
|
640
|
+
let start = attribute.start;
|
|
641
|
+
while (start > element.opening.start && /\s/.test(source[start - 1])) start--;
|
|
642
|
+
return {
|
|
643
|
+
ok: true,
|
|
644
|
+
edits: [replacement(source, start, attribute.end, "")]
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const literal = nodeOf(attribute, "value");
|
|
648
|
+
return {
|
|
649
|
+
ok: true,
|
|
650
|
+
edits: literal === null ? [replacement(source, attribute.end, attribute.end, `=${encodeAttribute(value)}`)] : [replacement(source, literal.start, literal.end, encodeAttribute(value))]
|
|
651
|
+
};
|
|
652
|
+
};
|
|
653
|
+
const INDENT = " ";
|
|
654
|
+
/** The indentation of the line `position` is on, when only whitespace precedes it there. */
|
|
655
|
+
const ownLineIndent = (source, position) => {
|
|
656
|
+
const lineStart = source.lastIndexOf("\n", position - 1) + 1;
|
|
657
|
+
const lead = source.slice(lineStart, position);
|
|
658
|
+
return /^[ \t]*$/.test(lead) ? lead : null;
|
|
659
|
+
};
|
|
660
|
+
/** Remove a child, and its line when it is alone on one. */
|
|
661
|
+
const removeChild = (source, node) => {
|
|
662
|
+
const indent = ownLineIndent(source, node.start);
|
|
663
|
+
const after = /^[ \t]*(\r?\n)/.exec(source.slice(node.end));
|
|
664
|
+
if (indent === null || after === null) return replacement(source, node.start, node.end, "");
|
|
665
|
+
const start = node.start - indent.length;
|
|
666
|
+
return replacement(source, start, node.end + after[0].length, "");
|
|
667
|
+
};
|
|
668
|
+
/** Insert markup on its own line before or after a sibling, or inline when the sibling is not alone. */
|
|
669
|
+
const insertBeside = (source, sibling, side, text) => {
|
|
670
|
+
const indent = ownLineIndent(source, sibling.start);
|
|
671
|
+
if (side === "before") return replacement(source, sibling.start, sibling.start, indent === null ? text : `${text}\n${indent}`);
|
|
672
|
+
return replacement(source, sibling.end, sibling.end, indent === null ? text : `\n${indent}${text}`);
|
|
673
|
+
};
|
|
674
|
+
/** The input fields read by the expressions inside `root`, as dotted paths. */
|
|
675
|
+
const variablesOf = (source, root, parameter) => {
|
|
676
|
+
const names = /* @__PURE__ */ new Set();
|
|
677
|
+
const scan = (text) => {
|
|
678
|
+
if (parameter?.type === "Identifier" && typeof parameter.name === "string") {
|
|
679
|
+
const pattern = new RegExp(`\\b${parameter.name}((?:\\??\\.[A-Za-z_$][\\w$]*)+)`, "g");
|
|
680
|
+
for (const match of text.matchAll(pattern)) names.add(match[1].replace(/\?/g, "").slice(1));
|
|
681
|
+
} else if (parameter?.type === "ObjectPattern") for (const property of nodeList(parameter.properties)) {
|
|
682
|
+
const key = nodeOf(property, "key");
|
|
683
|
+
if (key?.type === "Identifier" && typeof key.name === "string") {
|
|
684
|
+
if (new RegExp(`\\b${key.name}\\b`).test(text)) names.add(key.name);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
walk(root.node, (node) => {
|
|
689
|
+
if (node.type === "JSXExpressionContainer" && stringContainerValue(node) === null) scan(source.slice(node.start, node.end));
|
|
690
|
+
});
|
|
691
|
+
return [...names].sort();
|
|
692
|
+
};
|
|
693
|
+
const smsChannel = (source) => {
|
|
694
|
+
const tree = parseSource(source);
|
|
695
|
+
if (tree === null) return null;
|
|
696
|
+
const channel = channelFunction(tree, "sms");
|
|
697
|
+
if (channel === null || channel.root.tag !== importedAs(tree, "@samva/markup/sms", "Sms")) return null;
|
|
698
|
+
return {
|
|
699
|
+
...channel,
|
|
700
|
+
tree
|
|
701
|
+
};
|
|
702
|
+
};
|
|
703
|
+
/** The form model of the template's SMS body, or null when it is not a `<Sms>` written in place. */
|
|
704
|
+
const readSmsChannel = (source) => {
|
|
705
|
+
const channel = smsChannel(source);
|
|
706
|
+
if (channel === null) return null;
|
|
707
|
+
return {
|
|
708
|
+
category: attributeState(source, channel.root, "category"),
|
|
709
|
+
segments: segmentsOf(source, channel.root),
|
|
710
|
+
variables: variablesOf(source, channel.root, channel.parameter)
|
|
711
|
+
};
|
|
712
|
+
};
|
|
713
|
+
/** The source replacements that apply one form change to the SMS body. */
|
|
714
|
+
const editSmsChannel = (source, edit) => {
|
|
715
|
+
const channel = smsChannel(source);
|
|
716
|
+
if (channel === null) return {
|
|
717
|
+
ok: false,
|
|
718
|
+
reason: "The SMS body is not editable here."
|
|
719
|
+
};
|
|
720
|
+
if (edit.kind === "category") {
|
|
721
|
+
if (edit.value !== void 0 && !SMS_CATEGORIES.includes(edit.value)) return {
|
|
722
|
+
ok: false,
|
|
723
|
+
reason: `\`${edit.value}\` is not an SMS category.`
|
|
724
|
+
};
|
|
725
|
+
return setAttribute(source, channel.root, "category", edit.value);
|
|
726
|
+
}
|
|
727
|
+
return writeText(source, channel.root, segmentsOf(source, channel.root), edit.segment, edit.text);
|
|
728
|
+
};
|
|
729
|
+
const BUTTON_TAGS = {
|
|
730
|
+
"quick-reply": "QuickReplyButton",
|
|
731
|
+
url: "UrlButton",
|
|
732
|
+
phone: "PhoneButton",
|
|
733
|
+
"copy-code": "CopyCodeButton"
|
|
734
|
+
};
|
|
735
|
+
const BUTTON_DEFAULTS = {
|
|
736
|
+
"quick-reply": "Reply",
|
|
737
|
+
url: "Visit site",
|
|
738
|
+
phone: "Call us",
|
|
739
|
+
"copy-code": "CODE"
|
|
740
|
+
};
|
|
741
|
+
const kindOfTag = (tag, local) => {
|
|
742
|
+
if (!tag.startsWith(`${local}.`)) return null;
|
|
743
|
+
const part = tag.slice(local.length + 1);
|
|
744
|
+
return Object.keys(BUTTON_TAGS).find((kind) => BUTTON_TAGS[kind] === part) ?? null;
|
|
745
|
+
};
|
|
746
|
+
const whatsappChannel = (source) => {
|
|
747
|
+
const tree = parseSource(source);
|
|
748
|
+
if (tree === null) return null;
|
|
749
|
+
const channel = channelFunction(tree, "whatsapp");
|
|
750
|
+
const local = importedAs(tree, "@samva/markup/whatsapp", "WhatsApp");
|
|
751
|
+
if (channel === null || channel.root.tag !== local) return null;
|
|
752
|
+
const part = (name) => {
|
|
753
|
+
for (const child of channel.root.children) {
|
|
754
|
+
const element = elementOf(child);
|
|
755
|
+
if (element?.tag === `${local}.${name}`) return element;
|
|
756
|
+
}
|
|
757
|
+
return null;
|
|
758
|
+
};
|
|
759
|
+
return {
|
|
760
|
+
...channel,
|
|
761
|
+
local,
|
|
762
|
+
header: part("Header"),
|
|
763
|
+
body: part("Body"),
|
|
764
|
+
footer: part("Footer"),
|
|
765
|
+
buttons: part("Buttons")
|
|
766
|
+
};
|
|
767
|
+
};
|
|
768
|
+
const buttonRows = (source, channel) => {
|
|
769
|
+
if (channel.buttons === null) return [];
|
|
770
|
+
const rows = [];
|
|
771
|
+
for (const child of channel.buttons.children) {
|
|
772
|
+
if (child.type === "JSXText" && jsxTextValue(String(child.value ?? "")) === "") continue;
|
|
773
|
+
const element = elementOf(child);
|
|
774
|
+
const kind = element === null ? null : kindOfTag(element.tag, channel.local);
|
|
775
|
+
if (element === null || kind === null) {
|
|
776
|
+
rows.push({
|
|
777
|
+
row: {
|
|
778
|
+
kind: "locked",
|
|
779
|
+
summary: summarize(source, child)
|
|
780
|
+
},
|
|
781
|
+
node: child
|
|
782
|
+
});
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const segments = segmentsOf(source, element);
|
|
786
|
+
const label = segments[0];
|
|
787
|
+
rows.push({
|
|
788
|
+
node: child,
|
|
789
|
+
row: segments.length === 1 && label?.kind === "text" ? {
|
|
790
|
+
kind: "editable",
|
|
791
|
+
type: kind,
|
|
792
|
+
text: label.text,
|
|
793
|
+
url: attributeState(source, element, "url"),
|
|
794
|
+
phone: attributeState(source, element, "phone")
|
|
795
|
+
} : {
|
|
796
|
+
kind: "locked",
|
|
797
|
+
summary: summarize(source, child)
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
return rows;
|
|
802
|
+
};
|
|
803
|
+
/** The form model of the template's WhatsApp body, or null when it is not a `<WhatsApp>` written in place. */
|
|
804
|
+
const readWhatsAppChannel = (source) => {
|
|
805
|
+
const channel = whatsappChannel(source);
|
|
806
|
+
if (channel === null || channel.body === null) return null;
|
|
807
|
+
const { root } = channel;
|
|
808
|
+
return {
|
|
809
|
+
name: attributeState(source, root, "name"),
|
|
810
|
+
language: attributeState(source, root, "language"),
|
|
811
|
+
category: attributeState(source, root, "category"),
|
|
812
|
+
header: channel.header === null ? null : segmentsOf(source, channel.header),
|
|
813
|
+
headerType: channel.header === null ? { kind: "absent" } : attributeState(source, channel.header, "type"),
|
|
814
|
+
body: segmentsOf(source, channel.body),
|
|
815
|
+
footer: channel.footer === null ? null : segmentsOf(source, channel.footer),
|
|
816
|
+
buttons: buttonRows(source, channel).map(({ row }) => row),
|
|
817
|
+
variables: variablesOf(source, root, channel.parameter)
|
|
818
|
+
};
|
|
819
|
+
};
|
|
820
|
+
const buttonMarkup = (local, type, text, attributes) => {
|
|
821
|
+
const tag = `${local}.${BUTTON_TAGS[type]}`;
|
|
822
|
+
return `<${tag}${type === "url" && attributes.url !== void 0 ? ` url=${encodeAttribute(attributes.url)}` : type === "phone" && attributes.phone !== void 0 ? ` phone=${encodeAttribute(attributes.phone)}` : ""}>${encodeText(text, false, false)}</${tag}>`;
|
|
823
|
+
};
|
|
824
|
+
const newButton = (local, type) => buttonMarkup(local, type, BUTTON_DEFAULTS[type], {
|
|
825
|
+
url: "https://example.com",
|
|
826
|
+
phone: "+15555550100"
|
|
827
|
+
});
|
|
828
|
+
const editPart = (source, channel, part, index, text) => {
|
|
829
|
+
const element = channel[part];
|
|
830
|
+
if (element === null) {
|
|
831
|
+
if (part === "body" || text === "" || index !== 0) return {
|
|
832
|
+
ok: true,
|
|
833
|
+
edits: []
|
|
834
|
+
};
|
|
835
|
+
const tag = `${channel.local}.${part === "header" ? "Header" : "Footer"}`;
|
|
836
|
+
const markup = `<${tag}>${encodeText(text, false, false)}</${tag}>`;
|
|
837
|
+
const anchor = part === "header" ? channel.body.node : channel.buttons?.node ?? channel.body.node;
|
|
838
|
+
return {
|
|
839
|
+
ok: true,
|
|
840
|
+
edits: [insertBeside(source, anchor, part === "header" ? "before" : "after", markup)]
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
const segments = segmentsOf(source, element);
|
|
844
|
+
if (part !== "body" && text === "" && segments.length === 1) return {
|
|
845
|
+
ok: true,
|
|
846
|
+
edits: [removeChild(source, element.node)]
|
|
847
|
+
};
|
|
848
|
+
return writeText(source, element, segments, index, text);
|
|
849
|
+
};
|
|
850
|
+
/** The source replacements that apply one form change to the WhatsApp body. */
|
|
851
|
+
const editWhatsAppChannel = (source, edit) => {
|
|
852
|
+
const channel = whatsappChannel(source);
|
|
853
|
+
if (channel === null || channel.body === null) return {
|
|
854
|
+
ok: false,
|
|
855
|
+
reason: "The WhatsApp body is not editable here."
|
|
856
|
+
};
|
|
857
|
+
const { root, local } = channel;
|
|
858
|
+
switch (edit.kind) {
|
|
859
|
+
case "attribute": {
|
|
860
|
+
if (edit.name === "category" && !WHATSAPP_CATEGORIES.includes(edit.value ?? "")) return {
|
|
861
|
+
ok: false,
|
|
862
|
+
reason: "Choose a WhatsApp category."
|
|
863
|
+
};
|
|
864
|
+
const value = edit.name === "name" && edit.value === "" ? void 0 : edit.value ?? "";
|
|
865
|
+
return setAttribute(source, root, edit.name, value);
|
|
866
|
+
}
|
|
867
|
+
case "headerType":
|
|
868
|
+
if (channel.header === null) return {
|
|
869
|
+
ok: false,
|
|
870
|
+
reason: "Add the header text before choosing its type."
|
|
871
|
+
};
|
|
872
|
+
if (!WHATSAPP_HEADER_TYPES.includes(edit.value)) return {
|
|
873
|
+
ok: false,
|
|
874
|
+
reason: `\`${edit.value}\` is not a header type.`
|
|
875
|
+
};
|
|
876
|
+
return setAttribute(source, channel.header, "type", edit.value === "text" ? void 0 : edit.value);
|
|
877
|
+
case "text": return editPart(source, channel, edit.part, edit.segment, edit.text);
|
|
878
|
+
case "buttonAdd": {
|
|
879
|
+
const button = newButton(local, edit.type);
|
|
880
|
+
const buttons = channel.buttons;
|
|
881
|
+
if (buttons === null) {
|
|
882
|
+
const anchor = channel.footer ?? channel.body;
|
|
883
|
+
const tag = `${local}.Buttons`;
|
|
884
|
+
const indent = ownLineIndent(source, anchor.node.start);
|
|
885
|
+
const markup = indent === null ? `<${tag}>${button}</${tag}>` : `<${tag}>\n${indent}${INDENT}${button}\n${indent}</${tag}>`;
|
|
886
|
+
return {
|
|
887
|
+
ok: true,
|
|
888
|
+
edits: [insertBeside(source, anchor.node, "after", markup)]
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
if (buttons.selfClosing) {
|
|
892
|
+
const tag = `${local}.Buttons`;
|
|
893
|
+
const indent = ownLineIndent(source, buttons.node.start);
|
|
894
|
+
const markup = indent === null ? `<${tag}>${button}</${tag}>` : `<${tag}>\n${indent}${INDENT}${button}\n${indent}</${tag}>`;
|
|
895
|
+
return {
|
|
896
|
+
ok: true,
|
|
897
|
+
edits: [replacement(source, buttons.node.start, buttons.node.end, markup)]
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
const last = buttons.children.findLast((child) => child.type !== "JSXText" || jsxTextValue(String(child.value ?? "")) !== "");
|
|
901
|
+
if (last !== void 0) return {
|
|
902
|
+
ok: true,
|
|
903
|
+
edits: [insertBeside(source, last, "after", button)]
|
|
904
|
+
};
|
|
905
|
+
const indent = ownLineIndent(source, buttons.node.start);
|
|
906
|
+
return {
|
|
907
|
+
ok: true,
|
|
908
|
+
edits: [replacement(source, buttons.contentStart, buttons.contentEnd, indent === null ? button : `\n${indent}${INDENT}${button}\n${indent}`)]
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const rows = buttonRows(source, channel);
|
|
913
|
+
const target = rows[edit.button];
|
|
914
|
+
if (target === void 0 || channel.buttons === null) return {
|
|
915
|
+
ok: false,
|
|
916
|
+
reason: "That button no longer exists."
|
|
917
|
+
};
|
|
918
|
+
if (edit.kind === "buttonRemove") return {
|
|
919
|
+
ok: true,
|
|
920
|
+
edits: [removeChild(source, rows.length === 1 ? channel.buttons.node : target.node)]
|
|
921
|
+
};
|
|
922
|
+
const element = elementOf(target.node);
|
|
923
|
+
if (target.row.kind === "locked" || element === null) return {
|
|
924
|
+
ok: false,
|
|
925
|
+
reason: "That button is locked. Edit it in Source."
|
|
926
|
+
};
|
|
927
|
+
switch (edit.kind) {
|
|
928
|
+
case "buttonText": return writeText(source, element, segmentsOf(source, element), 0, edit.text);
|
|
929
|
+
case "buttonAttribute": return setAttribute(source, element, edit.name, edit.value);
|
|
930
|
+
default: {
|
|
931
|
+
const { row } = target;
|
|
932
|
+
const keep = (state) => state.kind === "literal" ? state.value : void 0;
|
|
933
|
+
const markup = buttonMarkup(local, edit.type, row.text, {
|
|
934
|
+
url: keep(row.url) ?? "https://example.com",
|
|
935
|
+
phone: keep(row.phone) ?? "+15555550100"
|
|
936
|
+
});
|
|
937
|
+
return {
|
|
938
|
+
ok: true,
|
|
939
|
+
edits: [replacement(source, target.node.start, target.node.end, markup)]
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
//#endregion
|
|
945
|
+
export { JSX_SOURCE_LOCATION_PROP, applySourceReplacements, classNameEdit, completeJsxSourceLocation, editSmsChannel, editWhatsAppChannel, expressionReplacement, inspectSourceElement, inspectSourceFixtures, literalReplacement, readSmsChannel, readWhatsAppChannel, structuralReplacements };
|
|
946
|
+
|
|
947
|
+
//# sourceMappingURL=edit.js.map
|