Files
opencode/services/site/script/fold-settings.ts

700 lines
32 KiB
TypeScript

// Production only: Settings and Design mode are development tools, so a production page always
// renders the default settings (settings-defaults.json) with Design mode off. This is the JavaScript
// counterpart of cssVariantsForProduction() (script/css-variants-plugin.ts): before Solid compiles a module, it
// reads every `siteSettings.<key>` as the default's literal, `designMode()` as false and
// `import.meta.env.DEV` as false, then folds what that decides (`<Show>`, `<Switch>`/`<Match>`,
// ternaries, `&&`/`||`, `if`), so the branches production never takes, and everything only they
// import, leave the bundle. The server and the browser builds fold the same way, so hydration keys
// still match. The development server is untouched.
import { existsSync } from "node:fs"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { transformSync, types, type NodePath, type PluginObj } from "@babel/core"
import type { Plugin } from "vite"
import defaults from "../src/component/site/settings-defaults.json"
// Settings that production pages change at runtime, so they stay live: ⇧A and ⇧N turn the home
// announcement on and off on any page (site-banner.tsx). Production reads and writes them in a store
// of their own (liveModule), so the Settings store and everything it needs leave the bundle. The
// build fails if production code writes any other setting (see generateBundle below).
const live = new Set(["homeAnnouncement"])
const liveModule = "virtual:fold-settings-live"
// Design mode's reordering and renaming, which save only while Design mode is on.
const designWrites = [
"component/site/nav-order.tsx",
"component/site/products-order.tsx",
"component/site/home-order.tsx",
]
const root = path.resolve(import.meta.dirname, "..", "src")
const settingsModule = path.join(root, "component/site/settings.ts")
// Modules that export `siteSettings`: its own and Site's re-export.
const settingsModules = [settingsModule, path.join(root, "component/site/site.tsx")]
const designModules = [
path.join(root, "component/site/design-copy.ts"),
path.join(root, "component/site/design-mode.tsx"),
]
// A page's link preview card (lib/og.ts), from og-images.json, which script/og.ts writes before the
// build: `ogImage("/press")` becomes the card itself, so a page whose cards are all named that way
// doesn't download the list.
const ogModule = path.join(root, "lib/og.ts")
const ogCards = path.join(root, "lib/og-images.json")
const candidate =
/\b(siteSettings|siteSettingsDefaults|setSiteSetting|designMode|designEditing|designCopy|ogImage|import\.meta\.env\.(DEV|PROD))\b/
export function foldSettingsForProduction(): Plugin {
let cards: Record<string, unknown> = {}
return {
name: "fold-settings-for-production",
apply: "build",
enforce: "pre",
async buildStart() {
cards = existsSync(ogCards) ? JSON.parse(await readFile(ogCards, "utf8")) : {}
},
resolveId: (id) => (id === liveModule ? `\0${liveModule}` : undefined),
load(id) {
if (id !== `\0${liveModule}`) return
const values = Object.fromEntries([...live].map((key) => [key, valueAt([key])]))
return [
`import { createStore } from "solid-js/store"`,
`const [settings, set] = createStore(${JSON.stringify(values)})`,
`export default { settings, set }`,
].join("\n")
},
async transform(code, id) {
const file = id.split("?")[0]
const result = foldModule(code, file, cards)
if (!result?.code) return
// FOLD_DEBUG=<directory> writes each folded module there, to see what production compiles.
if (process.env.FOLD_DEBUG) {
const out = path.join(process.env.FOLD_DEBUG, this.environment.name, path.relative(root, file))
await mkdir(path.dirname(out), { recursive: true })
await writeFile(out, result.code)
}
return { code: result.code, map: result.map }
},
// Checks what reaches the output. A production write to a folded setting would change nothing
// on the page, so it fails the build: add the setting to `live` above (Settings never reaches
// the output, and Design mode's own modules, designWrites, only write while it is on). Any read
// left unfolded fails it too, as production's Settings store starts empty (settings.ts).
generateBundle(_, bundle) {
const modules = Object.values(bundle)
.flatMap((chunk) => (chunk.type === "chunk" ? Object.entries(chunk.modules) : []))
.map(([id, module]) => ({
file: id.split("?")[0],
code: (module.code ?? "").replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, ""),
}))
.filter((module) => module.file.startsWith(root + path.sep) && module.file !== settingsModule)
const name = (file: string) => path.relative(root, file)
const written = modules
.filter((module) => !designWrites.some((file) => module.file === path.join(root, file)))
.flatMap((module) =>
[...module.code.matchAll(/(?<!function )\bsetSiteSetting(?:\$\d+)?\(\s*(?:"(\w+)"|([^,)]+))/g)]
.map((match) => match[1] ?? `<${match[2].trim()}>`)
.map((key) => `${key} (${name(module.file)})`),
)
if (written.length)
this.error(
`fold-settings: production code writes folded settings: ${[...new Set(written)].join(", ")}. Add them to \`live\` in script/fold-settings.ts.`,
)
const read = modules
.filter((module) => /\bsiteSettings(?:\$\d+)?\b/.test(module.code))
.map((module) => name(module.file))
if (read.length)
this.error(
`fold-settings: production code reads Settings in a way the build can't fold (a dynamic key, or the whole store): ${[...new Set(read)].join(", ")}. Read each setting as siteSettings.<key>.`,
)
},
}
}
/**
* One module as production compiles it, or undefined when nothing in it folds. `trimImports` also
* drops imports left unused, for script/i18n-slices.ts, which reads the client's import graph to
* find the strings each page can show; the build keeps them, as an imported module's CSS still
* loads.
*/
export function foldModule(code: string, file: string, cards: Record<string, unknown> = {}, trimImports = false) {
if (!file.startsWith(root + path.sep) || !/\.(ts|tsx)$/.test(file)) return
if (file === settingsModule || designModules.includes(file)) return
if (!candidate.test(code)) return
// Babel installs its own Error.prepareStackTrace on first use, which breaks Error.captureStackTrace
// for non-Error objects under Bun (vite's errors, in the same test process); keep the original.
const prepare = Error.prepareStackTrace
const result = transformSync(code, {
filename: file,
babelrc: false,
configFile: false,
sourceMaps: true,
parserOpts: { plugins: ["jsx", "typescript"] },
plugins: [foldSettings(file, cards, trimImports)],
})
Error.prepareStackTrace = prepare
return result
}
type Imports = {
settings: Set<string>
defaults: Set<string>
setters: Set<string>
og: Set<string>
design: Map<string, string>
solid: Map<string, string>
}
function foldSettings(file: string, cards: Record<string, unknown>, trimImports: boolean) {
return (): PluginObj => ({
name: "fold-settings",
visitor: {
Program(program) {
const imports = readImports(program, file)
const hoisted = new Map<string, types.Identifier>()
// The live store, imported on first use.
let liveStore: types.Identifier | undefined
const store = () => (liveStore ??= program.scope.generateUidIdentifier("live"))
program.traverse({
MetaProperty(meta) {
const env = meta.parentPath.parentPath
if (!env?.isMemberExpression() || !meta.parentPath.isMemberExpression()) return
const object = meta.parentPath.node
if (!types.isIdentifier(object.property, { name: "env" }) || object.computed) return
const flag = env.node.property
if (env.node.computed || !types.isIdentifier(flag) || !["DEV", "PROD"].includes(flag.name)) return
if (isWritten(env)) return
env.replaceWith(types.booleanLiteral(flag.name === "PROD"))
},
Identifier(identifier) {
if (!identifier.isReferencedIdentifier()) return
const name = identifier.node.name
if (imports.settings.has(name) || imports.defaults.has(name)) {
replaceSetting(identifier, imports.settings.has(name), program, hoisted, store)
return
}
const call = identifier.parentPath
const key = call.isCallExpression() ? call.node.arguments[0] : undefined
if (
imports.og.has(name) &&
isImported(identifier, name) &&
call.isCallExpression({ callee: identifier.node })
)
if (types.isStringLiteral(key) && Object.hasOwn(cards, key.value))
call.replaceWith(types.valueToNode(cards[key.value]))
if (
imports.setters.has(name) &&
isImported(identifier, name) &&
call.isCallExpression({ callee: identifier.node })
)
if (types.isStringLiteral(key) && live.has(key.value))
identifier.replaceWith(types.memberExpression(types.cloneNode(store()), types.identifier("set")))
const design = imports.design.get(name)
if (!design || !isImported(identifier, name)) return
if (!call.isCallExpression() || call.node.callee !== identifier.node) return
const fallback = call.node.arguments[1]
if (design === "designMode" || design === "designEditing") call.replaceWith(types.booleanLiteral(false))
else if (design === "editing") call.replaceWith(types.identifier("undefined"))
else if (
design === "designCopy" &&
types.isExpression(fallback) &&
types.isStringLiteral(call.node.arguments[0])
)
call.replaceWith(fallback)
},
})
const body = program.get("body")
const last = body.findLast((statement) => statement.isImportDeclaration())
const declarations = [...hoisted].map(([key, uid]) =>
types.variableDeclaration("const", [types.variableDeclarator(uid, settingValue(key))]),
)
const liveImport = liveStore
? [types.importDeclaration([types.importDefaultSpecifier(liveStore)], types.stringLiteral(liveModule))]
: []
if (declarations.length && last) last.insertAfter(declarations)
if (declarations.length && !last) program.unshiftContainer("body", declarations)
if (liveImport.length) program.unshiftContainer("body", liveImport)
program.scope.crawl()
fold(program, imports)
if (trimImports) removeUnusedImports(program)
},
},
})
}
function removeUnusedImports(program: NodePath<types.Program>) {
program.get("body").forEach((statement) => {
if (!statement.isImportDeclaration() || !statement.node.specifiers.length) return
const used = statement.node.specifiers.filter(
(specifier) => program.scope.getBinding(specifier.local.name)?.referenced,
)
if (used.length === statement.node.specifiers.length) return
if (!used.length) return statement.remove()
statement.node.specifiers = used
})
}
function readImports(program: NodePath<types.Program>, file: string): Imports {
const imports: Imports = {
settings: new Set(),
defaults: new Set(),
setters: new Set(),
og: new Set(),
design: new Map(),
solid: new Map(),
}
program.get("body").forEach((statement) => {
if (!statement.isImportDeclaration() || statement.node.importKind === "type") return
const source = resolveSource(statement.node.source.value, file)
statement.node.specifiers.forEach((specifier) => {
if (!types.isImportSpecifier(specifier) || specifier.importKind === "type") return
const imported = types.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value
const local = specifier.local.name
if (source === "solid-js") return imports.solid.set(local, imported)
if (settingsModules.includes(source) && imported === "siteSettings") return imports.settings.add(local)
if (settingsModules.includes(source) && imported === "setSiteSetting") return imports.setters.add(local)
if (source === ogModule && imported === "ogImage") return imports.og.add(local)
if (source === settingsModule && imported === "siteSettingsDefaults") return imports.defaults.add(local)
if (designModules.includes(source) && ["designMode", "designEditing", "designCopy", "editing"].includes(imported))
imports.design.set(local, imported)
})
})
return imports
}
function resolveSource(source: string, file: string) {
if (source.startsWith("~/")) return withExtension(path.join(root, source.slice(2)))
if (source.startsWith(".")) return withExtension(path.resolve(path.dirname(file), source))
return source
}
function withExtension(file: string) {
if (/\.(ts|tsx)$/.test(file)) return file
const known = [...settingsModules, ...designModules, ogModule]
return [".ts", ".tsx"].map((extension) => file + extension).find((name) => known.includes(name)) ?? file
}
function isImported(identifier: NodePath<types.Identifier>, name: string) {
return identifier.scope.getBinding(name)?.kind === "module"
}
// `siteSettings.a.b` becomes the default's literal when it is a string, number, boolean or null, or
// a module constant holding the default (one per key path, so it keeps its identity) when it is an
// object or array. A bare `siteSettings` stays the store.
function replaceSetting(
identifier: NodePath<types.Identifier>,
store: boolean,
program: NodePath<types.Program>,
hoisted: Map<string, types.Identifier>,
liveStore: () => types.Identifier,
) {
if (!isImported(identifier, identifier.node.name)) return
const chain = memberChain(identifier)
if (!chain.keys.length) {
const read = store ? readKeys(identifier) : undefined
if (read) identifier.replaceWith(types.valueToNode(Object.fromEntries(read.map((key) => [key, valueAt([key])]))))
return
}
if (isWritten(chain.path)) return
if (store && live.has(chain.keys[0])) {
identifier.replaceWith(types.memberExpression(types.cloneNode(liveStore()), types.identifier("settings")))
return
}
const value = valueAt(chain.keys)
if (value === missing) return
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
chain.path.replaceWith(types.valueToNode(value))
return
}
const key = chain.keys.join(".")
const uid = hoisted.get(key) ?? program.scope.generateUidIdentifier(`setting_${chain.keys.join("_")}`)
hoisted.set(key, uid)
chain.path.replaceWith(types.cloneNode(uid))
}
const missing = Symbol("missing")
// The keys a local function reads from the settings passed to it (`searchLook(siteSettings)`), when
// it only ever reads static keys of that parameter, so the call can get those defaults instead.
function readKeys(identifier: NodePath<types.Identifier>) {
const call = identifier.parentPath
if (!call.isCallExpression() || !types.isIdentifier(call.node.callee)) return undefined
const index = call.node.arguments.indexOf(identifier.node)
const binding = call.scope.getBinding(call.node.callee.name)
const target = binding?.path.isVariableDeclarator() ? binding.path.get("init") : binding?.path
if (index < 0 || !target || (!target.isFunctionDeclaration() && !target.isArrowFunctionExpression())) return undefined
if (binding?.constantViolations.length) return undefined
const param = target.node.params[index]
if (!types.isIdentifier(param)) return undefined
const references = target.scope.getBinding(param.name)?.referencePaths ?? []
const keys = references.map((reference) => {
const member = reference.parentPath
if (!member?.isMemberExpression({ object: reference.node }) || isWritten(member)) return undefined
const key = staticKey(member.node)
return key !== undefined && valueAt([key]) !== missing ? key : undefined
})
if (!keys.length || keys.includes(undefined)) return undefined
return [...new Set(keys as string[])]
}
function valueAt(keys: string[]): unknown {
return keys.reduce<unknown>((value, key) => {
if (value === missing || typeof value !== "object" || value === null || !Object.hasOwn(value, key)) return missing
return (value as Record<string, unknown>)[key]
}, defaults)
}
function settingValue(key: string) {
return types.valueToNode(valueAt(key.split(".")))
}
// The longest run of static property reads off `identifier` (`siteSettings.carousel.xl`), stopping
// before a method call so `siteSettings.navConfig.find(...)` keeps the call.
function memberChain(identifier: NodePath<types.Identifier>) {
const keys: string[] = []
let current: NodePath = identifier
while (
current.parentPath?.isMemberExpression({ object: current.node }) ||
current.parentPath?.isOptionalMemberExpression({ object: current.node })
) {
const member = current.parentPath.node as types.MemberExpression
const key = staticKey(member)
if (key === undefined) break
const next = valueAt([...keys, key])
if (next === missing) break
keys.push(key)
current = current.parentPath
if (next === null || typeof next !== "object") break
}
return { keys, path: current }
}
function staticKey(member: types.MemberExpression) {
if (!member.computed && types.isIdentifier(member.property)) return member.property.name
if (member.computed && types.isStringLiteral(member.property)) return member.property.value
if (member.computed && types.isNumericLiteral(member.property)) return String(member.property.value)
return undefined
}
function isWritten(expression: NodePath) {
const parent = expression.parentPath
if (!parent) return false
if (parent.isAssignmentExpression({ left: expression.node })) return true
if (parent.isUpdateExpression()) return true
if (parent.isUnaryExpression({ operator: "delete" })) return true
return false
}
// Folds what the constants decide until nothing changes.
function fold(program: NodePath<types.Program>, imports: Imports) {
let changed = true
while (changed) {
changed = false
const mark = () => {
changed = true
}
program.traverse({
CallExpression: {
exit(call) {
const accessor = callValue(call)
// Babel decides string methods on literals ("3 columns".charAt(0)) and Math, Number and String.
const value = accessor === missing ? known(call) : accessor
if (value === missing) return
call.replaceWith(types.valueToNode(value))
mark()
},
},
// A static read of an object literal of constants (`{ src: "/og/home.png", alt }.src`).
MemberExpression: {
exit(member) {
const object = member.get("object")
const key = staticKey(member.node)
if (!object.isObjectExpression() || key === undefined || isWritten(member)) return
if (member.parentPath.isCallExpression({ callee: member.node })) return
const properties = object.node.properties
const plain = properties.every(
(property) =>
types.isObjectProperty(property) &&
!property.computed &&
(types.isIdentifier(property.key) || types.isStringLiteral(property.key)) &&
types.isLiteral(property.value) &&
!types.isTemplateLiteral(property.value),
)
if (!plain) return
const found = properties.findLast(
(property) =>
types.isObjectProperty(property) &&
(types.isIdentifier(property.key) ? property.key.name : (property.key as types.StringLiteral).value) ===
key,
) as types.ObjectProperty | undefined
if (!found) return
member.replaceWith(found.value as types.Expression)
mark()
},
},
ConditionalExpression: {
exit(conditional) {
const test = known(conditional.get("test"))
if (test === missing) return
conditional.replaceWith(test ? conditional.node.consequent : conditional.node.alternate)
mark()
},
},
LogicalExpression: {
exit(logical) {
const left = known(logical.get("left"))
if (left === missing) return
const operator = logical.node.operator
const keepLeft = operator === "&&" ? !left : operator === "||" ? !!left : left !== null && left !== undefined
logical.replaceWith(keepLeft ? logical.node.left : logical.node.right)
mark()
},
},
"BinaryExpression|UnaryExpression": {
exit(expression: NodePath) {
if (expression.isUnaryExpression() && !["!", "typeof"].includes(expression.node.operator)) return
if (expression.isBinaryExpression() && !["===", "!==", "==", "!="].includes(expression.node.operator)) return
const value = known(expression)
if (value === missing || !["boolean", "string"].includes(typeof value)) return
expression.replaceWith(types.valueToNode(value))
mark()
},
},
// Statements after a return or throw never run (a folded `if (!designMode()) return`); hoisted
// function and var declarations stay.
"ReturnStatement|ThrowStatement": {
exit(statement: NodePath) {
if (!Array.isArray(statement.container)) return
const after = statement.getAllNextSiblings()
// A function declared there is dead too when only dead code calls it.
const inside = (reference: NodePath) => after.some((next) => reference.isDescendant(next))
const dead = after.filter((next) => {
if (next.isVariableDeclaration({ kind: "var" })) return false
if (!next.isFunctionDeclaration() || !next.node.id) return true
return !!next.scope.parent?.getBinding(next.node.id.name)?.referencePaths.every(inside)
})
if (!dead.length) return
dead.forEach((next) => next.remove())
mark()
},
},
IfStatement: {
exit(statement) {
const test = known(statement.get("test"))
if (test === missing) return
const branch = test ? statement.node.consequent : statement.node.alternate
mark()
if (!branch || (types.isBlockStatement(branch) && !branch.body.length)) return statement.remove()
// A block's statements go in its place unless they declare something scoped to it.
const lexical = types.isBlockStatement(branch)
? branch.body.some(
(item) =>
types.isFunctionDeclaration(item) ||
types.isClassDeclaration(item) ||
(types.isVariableDeclaration(item) && item.kind !== "var"),
)
: true
if (types.isBlockStatement(branch) && !lexical && Array.isArray(statement.container))
statement.replaceWithMultiple(branch.body)
else statement.replaceWith(branch)
},
},
JSXExpressionContainer: {
exit(container) {
if (!container.parentPath.isJSXElement() && !container.parentPath.isJSXFragment()) return
// Nothing to render: a boolean, null or undefined, written out or decided.
const value = known(container.get("expression"))
if (value !== null && value !== undefined && typeof value !== "boolean") return
container.remove()
mark()
},
},
JSXElement: {
exit(element) {
const name = element.node.openingElement.name
if (!types.isJSXIdentifier(name)) return
const component = imports.solid.get(name.name)
if (component === "Show" && foldShow(element)) return mark()
if (component === "Switch" && foldSwitch(element, imports)) return mark()
},
},
})
program.scope.crawl()
}
}
// A call to a local `const name = (...) => <constant>` arrow is its constant when the arguments are
// plain reads (`groupStart(index())`), and `["a", "b"].includes(<constant>)` is decided too.
function callValue(call: NodePath<types.CallExpression>): unknown {
const callee = call.get("callee")
if (callee.isIdentifier() && call.node.arguments.every(plainRead)) {
const binding = call.scope.getBinding(callee.node.name)
if (!binding || binding.kind !== "const" || binding.constantViolations.length) return missing
const init = binding.path.isVariableDeclarator() ? binding.path.get("init") : undefined
if (!init?.isArrowFunctionExpression() || init.node.async) return missing
const body = init.get("body")
if (Array.isArray(body) || !body.isExpression()) return missing
const value = known(body)
return value === missing || (value !== null && typeof value === "object") ? missing : value
}
if (callee.isMemberExpression() && types.isIdentifier(callee.node.property, { name: "includes" })) {
const list = callee.get("object")
const argument = call.get("arguments")[0]
if (!list.isArrayExpression() || !argument || call.node.arguments.length !== 1 || !argument.isExpression())
return missing
const items = list.get("elements").map((item) => (item.isExpression() ? known(item) : missing))
const value = known(argument)
if (value === missing || items.includes(missing)) return missing
return items.includes(value)
}
return missing
}
// An argument whose evaluation does nothing but read: a literal, a name, a property, or a call to
// an accessor with no arguments.
function plainRead(argument: types.Node): boolean {
if (types.isLiteral(argument) || types.isIdentifier(argument)) return true
if (types.isMemberExpression(argument)) return !argument.computed && plainRead(argument.object)
return types.isCallExpression(argument) && !argument.arguments.length && types.isIdentifier(argument.callee)
}
// A constant value for `expression` when it has one and no side effects are lost by folding it.
function known(expression: NodePath): unknown {
if (!expression.isExpression()) return missing
let effects = false
// `void work()` evaluates to undefined, but the work still has to run.
const discards = (node: types.Node) =>
types.isUnaryExpression(node, { operator: "void" }) && !types.isLiteral(node.argument)
expression.traverse({
"SequenceExpression|AssignmentExpression|UpdateExpression|AwaitExpression|YieldExpression"() {
effects = true
},
UnaryExpression(unary) {
if (discards(unary.node)) effects = true
},
})
if (effects || expression.isSequenceExpression() || discards(expression.node)) return missing
const result = expression.evaluate()
if (!result.confident) return missing
const value = result.value
if (typeof value === "function" || (typeof value === "object" && value !== null)) return missing
return value
}
function attribute(element: NodePath<types.JSXElement>, name: string) {
return element.node.openingElement.attributes.find(
(item): item is types.JSXAttribute => types.isJSXAttribute(item) && types.isJSXIdentifier(item.name, { name }),
)
}
function attributeExpression(element: NodePath<types.JSXElement>, name: string) {
const index = element.node.openingElement.attributes.findIndex(
(item) => types.isJSXAttribute(item) && types.isJSXIdentifier(item.name, { name }),
)
if (index < 0) return undefined
const value = element.get(`openingElement.attributes.${index}.value`) as NodePath
if (value.isJSXExpressionContainer()) return value.get("expression") as NodePath
return value
}
function hasSpread(element: NodePath<types.JSXElement>) {
return element.node.openingElement.attributes.some((item) => types.isJSXSpreadAttribute(item))
}
// The children to render in place of the element, or undefined when they're a callback
// (`{(value) => ...}`), which needs the `when` value. Whitespace-only text with a line break, which
// JSX drops, is left out.
function contents(children: types.JSXElement["children"]) {
const kept = children.filter(
(child) => !(types.isJSXText(child) && !child.value.trim() && child.value.includes("\n")),
)
if (kept.some((child) => types.isJSXExpressionContainer(child) && types.isFunction(child.expression)))
return undefined
return kept
}
function fallbackOf(element: NodePath<types.JSXElement>) {
const value = attribute(element, "fallback")?.value
if (!value) return null
if (types.isJSXExpressionContainer(value))
return types.isJSXEmptyExpression(value.expression) ? null : value.expression
return value
}
// Puts `replacement` (an expression, or JSX children) where the element was: spliced into the
// parent's children inside JSX, or as one expression elsewhere.
function replaceElement(
element: NodePath<types.JSXElement>,
replacement: types.Expression | types.JSXElement["children"] | null,
) {
const inJsx = element.parentPath.isJSXElement() || element.parentPath.isJSXFragment()
const children = (replacement === null ? [] : Array.isArray(replacement) ? replacement : [replacement]).map(
(child) =>
types.isJSXElement(child) ||
types.isJSXFragment(child) ||
types.isJSXText(child) ||
types.isJSXExpressionContainer(child) ||
types.isJSXSpreadChild(child)
? child
: types.jsxExpressionContainer(child),
)
if (inJsx) {
if (!children.length) return element.remove()
return element.replaceWithMultiple(children)
}
if (!children.length) return element.replaceWith(types.identifier("undefined"))
const only = children[0]
if (children.length === 1 && (types.isJSXElement(only) || types.isJSXFragment(only))) return element.replaceWith(only)
if (children.length === 1 && types.isJSXExpressionContainer(only) && types.isExpression(only.expression))
return element.replaceWith(only.expression)
element.replaceWith(types.jsxFragment(types.jsxOpeningFragment(), types.jsxClosingFragment(), children))
}
function foldShow(element: NodePath<types.JSXElement>) {
if (hasSpread(element)) return false
const when = attributeExpression(element, "when")
if (!when) return false
const value = known(when)
if (value === missing) return false
if (!value) {
replaceElement(element, fallbackOf(element))
return true
}
const shown = contents(element.node.children)
if (shown === undefined) return false
replaceElement(element, shown)
return true
}
// Drops each `<Match>` whose `when` is false; a first `<Match>` that is true becomes the whole switch.
function foldSwitch(element: NodePath<types.JSXElement>, imports: Imports) {
if (hasSpread(element)) return false
const matches = element.get("children").filter((child): child is NodePath<types.JSXElement> => {
if (!child.isJSXElement()) return false
const name = child.node.openingElement.name
return types.isJSXIdentifier(name) && imports.solid.get(name.name) === "Match"
})
if (
matches.length !== element.node.children.filter((child) => !(types.isJSXText(child) && !child.value.trim())).length
)
return false
const decided = matches.map((match) => {
const when = attributeExpression(match, "when")
const value = hasSpread(match) || !when ? missing : known(when)
return value === missing ? missing : !!value
})
// Matches that can never be chosen: false ones, and every one after a true one.
const chosen = decided.indexOf(true)
const kept = matches.filter((_, index) => decided[index] !== false && (chosen < 0 || index <= chosen))
if (!kept.length) {
replaceElement(element, fallbackOf(element))
return true
}
const shown = decided[matches.indexOf(kept[0])] === true ? contents(kept[0].node.children) : undefined
if (shown !== undefined) {
replaceElement(element, shown)
return true
}
if (kept.length === matches.length) return false
matches.filter((match) => !kept.includes(match)).forEach((match) => match.remove())
return true
}