Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions src/Edge/Components/Native/Text.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

class Text extends NativeBladeComponent
{
/**
* Skips the base withAttributes emission, which also means this
* component's frame opens AFTER its slot has rendered, unlike
* containers whose element opens before the slot runs.
*/
protected bool $handlesCollectorManually = true;

protected function elementType(): string
Expand All @@ -16,17 +21,19 @@ protected function elementType(): string
public function render(): \Closure
{
return function (array $data) {
$attrs = $data['attributes']->getAttributes();
$text = preg_replace('/\s+/', ' ', trim(html_entity_decode(strip_tags($data['slot']->toHtml()), ENT_QUOTES, 'UTF-8')));
if ($text !== '') {
$attrs['text'] = $text;
}

if (NativeElementCollector::isStreaming()) {
NativeElementCollector::leafStreaming($this->elementType(), $attrs);
} else {
NativeElementCollector::leaf($this->elementType(), $attrs);
}
// Run the same frame cycle as a precompiled <text> tag, so a
// nested component becomes a run of its parent, inheriting
// its whitespace mode, while a top-level one merges slot
// and attribute text through the identical policy. The
// close stays streaming-aware, like any <text>.
//
// Blade closed the slot-capture buffer before this closure
// runs, so the echoed HTML is inert text and textOpen's
// buffer handoff sees the enclosing frame, never the
// slot's. The cycle depends on that ordering.
NativeElementCollector::textOpen($data['attributes']->getAttributes());
echo $data['slot']->toHtml();
NativeElementCollector::textClose();

return '';
};
Expand Down
4 changes: 4 additions & 0 deletions src/Edge/Element.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ public function setChildren(array $children): static
* `NativeElementCollector` uses for blade-driven elements, so the
* resolved keys map to the same node fields with no behavioural
* drift between blade and programmatic construction.
*
* One exception: `whitespace-*` is a capture-time policy that only
* blade-authored text passes through. Programmatic text strings
* ship verbatim, the way code-built values are literal.
*/
public function class(string $classes): static
{
Expand Down
203 changes: 180 additions & 23 deletions src/Edge/NativeElementCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -1083,18 +1083,37 @@ public static function leaf(string $type, array $attrs): void

// ── Inline text runs (<text> with nested <text>) ─────

/**
* Default whitespace mode for text when no whitespace-* class is
* present. Both sources collapse like the browser does at paint
* time, so slot and attribute text can never disagree; adding
* whitespace-pre-wrap or pre-line opts any element back out.
*/
protected const DEFAULT_WHITESPACE = 'normal';

/**
* Begin capturing a `<text>` element's slot as ordered inline runs.
* Flushes the parent `<text>`'s pending raw text as a run first (so a
* nested run lands in document order), then buffers this element's content.
*/
public static function textOpen(array $attrs): void
{
$inherited = null;

if (! empty(static::$textFrames)) {
static::captureRawRunIntoTopFrame();

// Resolve the parent's mode as this frame opens, so a nested
// run inherits the closest classed ancestor, mirroring
// how the CSS white-space property cascades down.
$inherited = end(static::$textFrames)['whitespace'];
}

static::$textFrames[] = ['attrs' => $attrs, 'children' => []];
static::$textFrames[] = [
'attrs' => $attrs,
'children' => [],
'whitespace' => static::whitespaceMode($attrs) ?? $inherited,
];
ob_start();
}

Expand All @@ -1110,24 +1129,34 @@ public static function textClose(): void
$buffer = ob_get_clean();
$frame = array_pop(static::$textFrames);
$isNested = ! empty(static::$textFrames);
$mode = $frame['whitespace'];

if (! empty($frame['children'])) {
// Container: its own trailing buffer is a run; own text stays empty.
$tail = static::normalizeRunText($buffer);
$tail = static::normalizeRunText($buffer, $mode);
if ($tail !== '') {
$frame['children'][] = ['attrs' => ['text' => $tail], 'children' => null];
}
$descriptor = ['attrs' => $frame['attrs'], 'children' => $frame['children']];
} else {
// Childless <text>: a RUN when nested (preserve meaningful edge
// spaces), but a top-level leaf keeps today's exact trimmed +
// whitespace-collapsed string so nothing else regresses.
$attrs = $frame['attrs'];
$text = $isNested ? static::normalizeRunText($buffer) : static::normalizeLeafText($buffer);
$descriptor = [
'attrs' => static::applyAttributeWhitespace($frame['attrs'], $mode),
'children' => $frame['children'],
];
} elseif ($isNested) {
// Childless nested <text>: a RUN, normalized without trimming so
// meaningful edge spaces survive, under the inherited mode. Its
// attribute text gets the run-shaped transform for the same
// reason: the browser trims at block boundaries only, so a
// separator run like `:text="' / '"` keeps its spacing.
$attrs = static::applyRunAttributeWhitespace($frame['attrs'], $mode);
$text = static::normalizeRunText($buffer, $mode);
if ($text !== '') {
$attrs['text'] = $text;
}
$descriptor = ['attrs' => $attrs, 'children' => null];
} else {
// Top-level leaf: merge slot and attribute text through the
// shared whitespace policy (per-source defaults intact).
$descriptor = ['attrs' => static::mergeSlotText($frame['attrs'], $buffer, $mode), 'children' => null];
}

if ($isNested) {
Expand All @@ -1141,37 +1170,165 @@ public static function textClose(): void
}
}

/**
* A self-closing `<text />` is a paired tag with an empty slot, so it
* runs the exact open/close cycle the paired form does. That makes
* a nested one a RUN of its parent in document order, where it
* used to escape the frame as a misplaced sibling.
*/
public static function textLeaf(array $attrs): void
{
static::textOpen($attrs);
static::textClose();
}

/**
* Merge a captured slot into a text element's attrs under the shared
* whitespace policy, applying the default when no class is present.
* Only textClose calls this, with the mode its frame resolved at
* open time, so every authoring form funnels through one merge.
*/
protected static function mergeSlotText(array $attrs, string $rawSlot, ?string $mode): array
{
$attrs = static::applyAttributeWhitespace($attrs, $mode);

$text = static::normalizeLeafText($rawSlot, $mode);
if ($text !== '') {
$attrs['text'] = $text;
}

return $attrs;
}

/**
* Resolve the whitespace-* utility governing an element's text from
* its raw class attribute, or null when the class carries none.
* TailwindParser caches per class string, so this is cheap.
*/
protected static function whitespaceMode(array $attrs): ?string
{
if (! is_string($attrs['class'] ?? null)) {
return null;
}

return TailwindParser::parse($attrs['class'])['whitespace'] ?? null;
}

/**
* Apply the whitespace policy to attribute-sourced text. With no
* class present the attribute default collapses like the slot
* does, so both sources of text behave identically.
*/
protected static function applyAttributeWhitespace(array $attrs, ?string $mode): array
{
if (isset($attrs['text']) && is_string($attrs['text'])) {
$attrs['text'] = static::applyWhitespace($attrs['text'], $mode);
}

return $attrs;
}

/**
* Run-shaped counterpart for attribute text on nested runs: collapse
* without trimming so explicit edge spaces survive, and never drop
* the string, since `:text` content is intentional rather than
* template formatting like raw inter-tag whitespace is.
*/
protected static function applyRunAttributeWhitespace(array $attrs, ?string $mode): array
{
if (isset($attrs['text']) && is_string($attrs['text'])) {
$attrs['text'] = static::applyRunWhitespace($attrs['text'], $mode);
}

return $attrs;
}

/**
* Run-shaped whitespace transform: collapse without trimming, so a
* run keeps whatever edge spacing its author gave it. The browser
* behaves the same way, trimming at block boundaries only.
*/
protected static function applyRunWhitespace(string $text, ?string $mode): string
{
return match ($mode ?? static::DEFAULT_WHITESPACE) {
'pre-wrap' => $text,
'pre-line' => static::collapsePreLine($text),
default => preg_replace('/\s+/', ' ', $text),
};
}

/**
* The pre-line collapse: newline runs become one newline first, then
* the remaining horizontal runs collapse to single spaces. The two
* passes only make sense together and in this order.
*/
protected static function collapsePreLine(string $text): string
{
return preg_replace('/[^\S\n]+/', ' ', preg_replace('/[^\S\n]*\n[^\S\n]*/', "\n", $text));
}

/**
* Leaf-shaped whitespace transform: 'normal' trims and collapses every
* whitespace run, 'pre-line' keeps newlines while collapsing the
* horizontal runs around them and trimming, 'pre-wrap' touches nothing.
*/
protected static function applyWhitespace(string $text, ?string $mode): string
{
return match ($mode ?? static::DEFAULT_WHITESPACE) {
'pre-wrap' => $text,
'pre-line' => trim(static::collapsePreLine($text)),
default => preg_replace('/\s+/', ' ', trim($text)),
};
}

/** Flush the currently-buffered raw text as a run of the top frame. */
protected static function captureRawRunIntoTopFrame(): void
{
$text = static::normalizeRunText(ob_get_clean());
$top = count(static::$textFrames) - 1;
$text = static::normalizeRunText(ob_get_clean(), static::$textFrames[$top]['whitespace']);
if ($text !== '') {
$top = count(static::$textFrames) - 1;
static::$textFrames[$top]['children'][] = ['attrs' => ['text' => $text], 'children' => null];
}
}

/**
* Whitespace policy for a raw run: drop pure-whitespace segments (inter-tag
* newlines/indentation are formatting, not content) so multiline markup
* doesn't inject spurious spaces; collapse internal runs of whitespace to a
* single space but PRESERVE meaningful leading/trailing spaces (a run may be
* `" / "`, or prose like `"Use "` before an inline chip).
* Whitespace policy for a raw run. The default (and 'normal') drops
* pure-whitespace segments (inter-tag newlines/indentation are
* formatting, not content) and collapses internal whitespace runs to a
* single space while PRESERVING meaningful leading/trailing spaces (a
* run may be `" / "`, or prose like `"Use "` before an inline chip).
* 'pre-line' keeps newlines and never trims, so run boundaries
* still carry their spacing; 'pre-wrap' keeps the run verbatim.
*/
protected static function normalizeRunText(string $raw): string
protected static function normalizeRunText(string $raw, ?string $mode = null): string
{
$s = html_entity_decode(strip_tags($raw), ENT_QUOTES, 'UTF-8');
if (trim($s) === '') {
$text = html_entity_decode(strip_tags($raw), ENT_QUOTES, 'UTF-8');

// Under the default collapse a purely-whitespace raw segment is
// template formatting rather than content, so it drops before
// the shared run transform handles everything that remains.
if (($mode ?? static::DEFAULT_WHITESPACE) === 'normal' && trim($text) === '') {
return '';
}

return preg_replace('/\s+/', ' ', $s);
return static::applyRunWhitespace($text, $mode);
}

/** Leaf `<text>` text: today's exact behavior (trim + collapse). */
protected static function normalizeLeafText(string $raw): string
/**
* Leaf `<text>` slot text. A formatting-only slot counts as "no slot"
* in every mode, so a paired tag written with pretty indentation can
* never clobber `:text`; anything else goes through the policy,
* which defaults to today's exact trim-and-collapse shape.
*/
protected static function normalizeLeafText(string $raw, ?string $mode = null): string
{
return preg_replace('/\s+/', ' ', trim(html_entity_decode(strip_tags($raw), ENT_QUOTES, 'UTF-8')));
$text = html_entity_decode(strip_tags($raw), ENT_QUOTES, 'UTF-8');

if (trim($text) === '') {
return '';
}

return static::applyWhitespace($text, $mode);
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/Edge/NativeTagPrecompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,13 @@ private function compileSelfClosing(string $tag, string $rawAttrs): string
$type = $this->tagToType($tag);
$attrs = $this->compileAttributes($rawAttrs);

// A self-closing <text /> carries attribute text only. Route it
// through textLeaf so a whitespace-* class governs `:text`
// exactly as the paired form does at textClose.
if ($tag === 'text') {
return '<?php '.self::C."::textLeaf({$attrs}); ?>";
}

// <native:virtual-list /> is special: open the element, loop the
// window, render the `item` Blade view once per index (each render
// streams its own native tags into the same collector), then close.
Expand Down
8 changes: 8 additions & 0 deletions src/Edge/TailwindParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,14 @@ private static function parseClassImpl(string $class): ?array
$class === 'select-text' => ['selectable' => 1],
$class === 'select-none' => ['selectable' => 0],

// Whitespace (CSS white-space) for text content. Consumed by the
// PHP capture layer before the text prop is serialized, so it
// never rides the wire. `nowrap` and `pre-wrap` need native
// line-wrap props and stay unparsed for now.
$class === 'whitespace-normal' => ['whitespace' => 'normal'],
$class === 'whitespace-pre-line' => ['whitespace' => 'pre-line'],
$class === 'whitespace-pre-wrap' => ['whitespace' => 'pre-wrap'],

// Letter spacing (tracking), in em (relative to font size).
$class === 'tracking-tighter' => ['letterSpacing' => -0.05],
$class === 'tracking-tight' => ['letterSpacing' => -0.025],
Expand Down
Loading
Loading