import { UIDLElementNode } from '@teleporthq/teleport-types'
import { HtmlContentModel, StyleDeclarations } from '@teleporthq/teleport-shared'
import { NestingResolverContext, ParagraphRepairPlan } from './types'
const { GENERIC_FLOW_TAG, GENERIC_PHRASING_TAG, PARAGRAPH_TAG } = HtmlContentModel
/**
* The `
` offenders that a tag substitution can fix without losing anything:
* a generic wrapper carries no semantics, so `` says exactly as much as
* `` did. Every other offender (a list, a heading, a form, a nested
* paragraph) means something, and silently renaming it would trade a rendering
* bug for a content bug.
*/
const isRetaggableParagraphChild = (tag: string): boolean => tag === GENERIC_FLOW_TAG
/**
* Every property that can supply a paragraph's block margins. One of them
* declared anywhere means the design already spaces this element and the UA
* default was never in play.
*/
const BLOCK_MARGIN_PROPERTIES: string[] = [
'margin',
'marginTop',
'marginBottom',
'marginBlock',
'marginBlockStart',
'marginBlockEnd',
]
const setStaticStyle = (node: UIDLElementNode, property: string, value: string): void => {
if (!node.content.style) {
node.content.style = {}
}
node.content.style[property] = { type: 'static', content: value }
}
/**
* Renames the element and keeps `semanticType` in step when it was carrying the
* same tag — some targets (the invoice HTML renderer, the Angular module
* builder) read `semanticType` in preference to `elementType`, so leaving a
* stale `p` there would re-emit the tag we just removed.
*/
const retagElement = (node: UIDLElementNode, tag: string): void => {
const previousTag = node.content.elementType
node.content.elementType = tag
if (node.content.semanticType === previousTag) {
node.content.semanticType = tag
}
}
export const createParagraphRepairPlan = (paragraph: UIDLElementNode): ParagraphRepairPlan => ({
paragraph,
retaggable: [],
blockingTags: [],
})
/**
* Files one offender against the paragraph it breaks and answers whether the
* paragraph is still open below it.
*
* Once the offender becomes a `
` the paragraph really does stay open, so
* anything deeper is still paragraph content and still has to be inspected. An
* offender that cannot be retagged has already closed the paragraph, and the
* rest of that branch is no longer inside it.
*/
export const recordParagraphOffender = (
plan: ParagraphRepairPlan,
node: UIDLElementNode,
tag: string
): boolean => {
if (isRetaggableParagraphChild(tag)) {
plan.retaggable.push(node)
return true
}
if (plan.blockingTags.indexOf(tag) === -1) {
plan.blockingTags.push(tag)
}
return false
}
/**
* `` is inline where `` is block, and that default is the only thing
* lost by the rename. Restate it — but ONLY when nothing else declares a
* display: node styles compile to a higher-specificity selector than project
* classes, so an unconditional `display: block` would override the very class
* it is meant to preserve.
*/
const retagAsPhrasingContent = (node: UIDLElementNode, context: NestingResolverContext): void => {
const displayState = StyleDeclarations.findStyleDeclaration(node, ['display'], {
projectStyleSetDefinitions: context.projectStyleSetDefinitions,
})
if (displayState === 'absent') {
setStaticStyle(node, 'display', 'block')
}
retagElement(node, GENERIC_PHRASING_TAG)
}
/**
* Last resort: the paragraph becomes a `
` so that whatever it holds is
* legal. A `
` gets no margins from the UA stylesheet, so the paragraph
* default is restated when — and only when — nothing else already spaces this
* element.
*
* That restatement assumes the UA default was in effect, which holds for every
* stylesheet this platform emits (project styles are class-scoped; there are no
* tag-level rules). A project that adds its own reset — a Tailwind preflight,
* say — zeroes paragraph margins, and there the 1em is spacing the design did
* not ask for. It is still the closer of the two guesses: the alternative drops
* spacing the design DID ask for, in the far more common un-reset case.
*/
const demoteParagraph = (node: UIDLElementNode, context: NestingResolverContext): void => {
const marginState = StyleDeclarations.findStyleDeclaration(node, BLOCK_MARGIN_PROPERTIES, {
projectStyleSetDefinitions: context.projectStyleSetDefinitions,
})
if (marginState === 'absent') {
setStaticStyle(node, 'marginTop', '1em')
setStaticStyle(node, 'marginBottom', '1em')
}
retagElement(node, GENERIC_FLOW_TAG)
}
export const applyParagraphRepairPlan = (
plan: ParagraphRepairPlan,
context: NestingResolverContext
): void => {
if (plan.blockingTags.length > 0) {
console.warn(
`[html-nesting] ${context.componentName}: <${PARAGRAPH_TAG}> contains ` +
`<${plan.blockingTags.join('>, <')}>, which the HTML parser cannot keep inside a ` +
`paragraph. Emitted as <${GENERIC_FLOW_TAG}> instead so the served markup matches ` +
`what was rendered (see nextjs.org/docs/messages/react-hydration-error).`
)
demoteParagraph(plan.paragraph, context)
return
}
if (plan.retaggable.length === 0) {
return
}
console.warn(
`[html-nesting] ${context.componentName}: ${plan.retaggable.length} <${GENERIC_FLOW_TAG}> ` +
`wrapper(s) inside a <${PARAGRAPH_TAG}> emitted as <${GENERIC_PHRASING_TAG}> — a ` +
`<${GENERIC_FLOW_TAG}> there makes the parser close the paragraph and break hydration.`
)
plan.retaggable.forEach((node) => retagAsPhrasingContent(node, context))
}