Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
0fa6606
Adds an HTML parser + template instrumentation to Antlers 😮‍💨
JohnathonKoster Sep 10, 2026
19f6b40
Field tracing
JohnathonKoster Sep 12, 2026
02895df
Live preview marker injection, field sync and highlights.
JohnathonKoster Sep 12, 2026
221af49
Refactors/a bit of cleanup
JohnathonKoster Sep 20, 2026
221f807
Some styling suggestions
jaygeorge Oct 1, 2026
eab3cce
Add an icon to the select field control
jaygeorge Oct 1, 2026
dcf33a6
Config adjustments
JohnathonKoster Oct 11, 2026
a4e0cef
Refine the Antlers HTML analyzer
JohnathonKoster Oct 11, 2026
eb49c7f
Update HTML instrumentation
JohnathonKoster Oct 11, 2026
21d722a
Update the Antlers parser and runtime for instrumentation
JohnathonKoster Oct 11, 2026
2d3a94f
Integrate instrumentation with tags, views and caching
JohnathonKoster Oct 11, 2026
3005f4b
Update instrumentation review notes
JohnathonKoster Oct 11, 2026
59266b1
Merge feat/antlers-html-instrumentation
JohnathonKoster Oct 13, 2026
6bb365e
Update field markers for the merged instrumentation API
JohnathonKoster Oct 13, 2026
72dd835
Select field enabled by default
JohnathonKoster Oct 13, 2026
68dbdc7
Cleanup
JohnathonKoster Oct 13, 2026
127337c
Merge 6.x
JohnathonKoster Oct 13, 2026
2964362
Collapse the instrumentation result memo into one tier
JohnathonKoster Oct 13, 2026
518b715
Make instrumentation manager mutators chainable
JohnathonKoster Oct 13, 2026
709e916
Add HTML analyzer and parser tests
JohnathonKoster Oct 13, 2026
60b1357
Add runtime instrumentation and tracing tests
JohnathonKoster Oct 13, 2026
f136200
Add view instrumentation tests
JohnathonKoster Oct 13, 2026
dc27039
Remove instrumentation review notes
JohnathonKoster Oct 13, 2026
e8ce9b4
Merge branch 'feat/antlers-html-instrumentation' into feat/antlers-fi…
JohnathonKoster Oct 14, 2026
8a1c579
Clear the render node cache from the instrumentation manager
JohnathonKoster Oct 14, 2026
89721be
Let instrumenters contribute marker patterns
JohnathonKoster Oct 14, 2026
34840d4
Merge branch 'feat/antlers-html-instrumentation' into feat/antlers-fi…
JohnathonKoster Oct 14, 2026
290024b
Use the Runtime facade in the live preview handler
JohnathonKoster Oct 14, 2026
d911a0c
Treat field helper tags as markers
JohnathonKoster Oct 14, 2026
dbd36a5
Make the field tracer an internal abstract base
JohnathonKoster Oct 14, 2026
d913b49
Complete the iframe manager mock in the live preview test
JohnathonKoster Oct 14, 2026
5623736
Avoid unsafe new static in instrumentation classes
JohnathonKoster Oct 14, 2026
be7c967
Merge branch 'feat/antlers-html-instrumentation' into feat/antlers-fi…
JohnathonKoster Oct 14, 2026
3f8e497
Trim prose from instrumentation docblocks
JohnathonKoster Oct 15, 2026
cd59ffd
Move analyzer lookup tables into the HTML spec
JohnathonKoster Oct 15, 2026
b42aafd
Merge branch 'feat/antlers-html-instrumentation' into feat/antlers-fi…
JohnathonKoster Oct 15, 2026
5d1292f
Add Antlers::parseWithoutInstrumentation
JohnathonKoster Oct 22, 2026
77fe070
Merge branch 'feat/antlers-html-instrumentation' into feat/antlers-fi…
JohnathonKoster Oct 22, 2026
e0182db
Merge remote-tracking branch 'origin/6.x' into feat/antlers-html-inst…
JohnathonKoster Oct 26, 2026
22e2fe7
Mark new instrumentation API as internal
jasonvarga Oct 29, 2026
0125984
Only publish enabled and cache instrumentation config
jasonvarga Oct 29, 2026
e862296
Use ASCII-only check for tag name starts
jasonvarga Oct 29, 2026
dad84e4
Split FieldSync.js into index, scrolling and highlight modules
JohnathonKoster Oct 29, 2026
50c69ad
Merge remote-tracking branch 'origin/feat/antlers-html-instrumentatio…
JohnathonKoster Oct 29, 2026
fe7162c
Merge remote-tracking branch 'origin/6.x' into feat/antlers-html-inst…
JohnathonKoster Oct 30, 2026
0da01a6
Merge branch 'feat/antlers-html-instrumentation' into feat/antlers-fi…
JohnathonKoster Oct 30, 2026
91f6edf
Merge branch '6.x' into feat/antlers-html-instrumentation
JohnathonKoster Nov 14, 2026
4087cb4
Merge branch 'feat/antlers-html-instrumentation' into feat/antlers-fi…
JohnathonKoster Nov 14, 2026
d45fa57
Fix Tests
JohnathonKoster Nov 14, 2026
02135a4
Update expectation
JohnathonKoster Nov 14, 2026
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
30 changes: 30 additions & 0 deletions config/antlers.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,36 @@

'debugbar' => env('STATAMIC_ANTLERS_DEBUGBAR', true),

/*
|--------------------------------------------------------------------------
| Tracing
|--------------------------------------------------------------------------
|
| When tracing is enabled, every tracer listed here is resolved from the
| container and notified as every Antlers node is entered and exited.
|
*/

'tracing' => env('STATAMIC_ANTLERS_TRACING', false),

'tracers' => [],

/*
|--------------------------------------------------------------------------
| HTML Instrumentation
|--------------------------------------------------------------------------
|
| When enabled, rendered Antlers output is annotated with markers that
| point back to the templates it came from. Since the markers expose
| template details, this is intended for development and tooling.
|
*/

'instrumentation' => [
'enabled' => env('STATAMIC_ANTLERS_INSTRUMENTATION', false),
'cache' => env('STATAMIC_ANTLERS_INSTRUMENTATION_CACHE', true),
],

/*
|--------------------------------------------------------------------------
| Guarded Variables
Expand Down
12 changes: 9 additions & 3 deletions resources/js/components/fieldtypes/GroupFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
@close="toggleFullscreen"
>
</publish-field-fullscreen-header>
<section :class="{ 'mt-14 p-4': fullScreenMode }">
<section ref="fieldsContainer" :class="{ 'mt-14 p-4': fullScreenMode }">
<div class="@container/panel" :class="{
'bg-white dark:bg-gray-800 dark:border-gray-900 rounded-lg border': config.border,
'hidden' : isCollapsed && !fullScreenMode
Expand All @@ -33,11 +33,12 @@
<script>
import Fieldtype from './Fieldtype.vue';
import ManagesPreviewText from './replicator/ManagesPreviewText';
import {PublishFields as Fields, PublishFieldsProvider as FieldsProvider} from '@ui';
import { PublishFields as Fields, PublishFieldsProvider as FieldsProvider } from '@ui';
import { reveal } from '@api';

export default {
mixins: [Fieldtype, ManagesPreviewText],
components: {Fields, FieldsProvider },
components: { Fields, FieldsProvider },
data() {
return {
containerWidth: null,
Expand All @@ -49,6 +50,11 @@ export default {
},
};
},
mounted() {
reveal.mount(this.$refs.fieldsContainer, () => {
this.isCollapsed = false;
});
},
computed: {
values() {
return this.value;
Expand Down
108 changes: 108 additions & 0 deletions resources/js/components/ui/LivePreview/FieldHighlights.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const outlineColor = '96 165 250';
const outlinePadding = 8;
const fadeDuration = 180;

export function createHighlightOverlay(doc) {
const win = doc.defaultView;
const duration = win.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : fadeDuration;

const overlay = doc.createElement('div');
overlay.setAttribute('aria-hidden', 'true');
overlay.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647;';
overlay.style.transition = `opacity ${duration}ms ease-out`;
doc.documentElement.append(overlay);

function createOutline(rect) {
const outline = doc.createElement('div');

Object.assign(outline.style, {
position: 'absolute',
left: `${rect.left - outlinePadding}px`,
top: `${rect.top - outlinePadding}px`,
width: `${rect.width + outlinePadding * 2}px`,
height: `${rect.height + outlinePadding * 2}px`,
border: `2px solid rgb(${outlineColor})`,
background: `rgb(${outlineColor} / 5%)`,
borderRadius: '5px',
boxSizing: 'border-box',
});

return outline;
}

function isOnScreen(rect) {
return rect.width && rect.height && rect.bottom >= 0 && rect.top <= win.innerHeight;
}

return {
fadeDuration: duration,

contains(node) {
return overlay.contains(node);
},

draw(entries, { faded = false } = {}) {
overlay.replaceChildren();
overlay.style.opacity = faded ? '0' : '1';

const drawn = new Set();

for (const entry of entries) {
const rect = entry.boundingRect();
if (!isOnScreen(rect)) continue;

const key = [rect.left, rect.top, rect.width, rect.height].join(',');
if (drawn.has(key)) continue;
drawn.add(key);

overlay.append(createOutline(rect));
}
},

dispose() {
overlay.remove();
},
};
}

export function createTimedHighlight(win, fadeDuration, onChange) {
let path = null;
let faded = false;
let fadeTimer;
let clearTimer;

function cancelTimers() {
win.clearTimeout(fadeTimer);
win.clearTimeout(clearTimer);
}

return {
get path() {
return path;
},

get faded() {
return faded;
},

show(newPath, duration) {
cancelTimers();
path = newPath;
faded = false;
onChange();

fadeTimer = win.setTimeout(() => {
faded = true;
onChange();

clearTimer = win.setTimeout(() => {
path = null;
faded = false;
onChange();
}, fadeDuration);
}, duration);
},

dispose: cancelTimers,
};
}
129 changes: 129 additions & 0 deletions resources/js/components/ui/LivePreview/FieldIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
export const fieldManifestId = 'statamic-preview-fields';

const fieldAttribute = 'data-statamic-field';
const markerComment = /^(\/?)statamic:field:(\d+)$/;

export function readFieldManifest(doc) {
let manifest;

try {
manifest = JSON.parse(doc.getElementById(fieldManifestId)?.textContent ?? 'null');
} catch {
return null;
}

return manifest?.owner && manifest.fields && manifest.markers ? manifest : null;
}

export function fieldPath(field) {
return field.path.join('.');
}

function createFieldEntry(fields, element, range, depth) {
const rects = () => (range ? Array.from(range.getClientRects()) : [element.getBoundingClientRect()]);

return {
fields,
element,
depth,
rects,
boundingRect: () => (range ?? element).getBoundingClientRect(),
contains: (x, y) => rects().some(rect => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom),
};
}

function isSelectable(entry) {
return entry.fields.length === 1;
}

export function createFieldIndex(doc, manifest) {
const byElement = new WeakMap();
const byPath = new Map();

function ownedFieldsFor(marker) {
return (manifest.markers[marker] ?? [])
.map(id => manifest.fields[id])
.filter(field => {
return Array.isArray(field?.path)
&& field.reference === manifest.owner.reference
&& field.locale === manifest.owner.locale;
});
}

function register(marker, element, range = null, depth = 0) {
const fields = ownedFieldsFor(marker);
if (!fields.length || !element) return;

const entry = createFieldEntry(fields, element, range, depth);

byElement.set(element, [...(byElement.get(element) ?? []), entry]);

for (const field of fields) {
const path = fieldPath(field);
byPath.set(path, [...(byPath.get(path) ?? []), entry]);
}
}

function rangeBetween(start, end) {
const range = doc.createRange();
range.setStartAfter(start);
range.setEndBefore(end);

return range;
}

function indexDocument() {
const win = doc.defaultView;
const walker = doc.createTreeWalker(doc.body, win.NodeFilter.SHOW_ELEMENT | win.NodeFilter.SHOW_COMMENT);
const openMarkers = new Map();
let depth = 0;

const indexElement = element => {
if (element.hasAttribute(fieldAttribute)) register(element.getAttribute(fieldAttribute), element);
};

const indexComment = comment => {
const match = comment.data.match(markerComment);
if (!match) return;

const [, closing, marker] = match;

if (!closing) {
openMarkers.set(marker, { node: comment, depth: ++depth });
return;
}

const opened = openMarkers.get(marker);
openMarkers.delete(marker);
depth = Math.max(0, depth - 1);

if (opened && opened.node.parentNode === comment.parentNode) {
register(marker, comment.parentElement, rangeBetween(opened.node, comment), opened.depth);
}
};

while (walker.nextNode()) {
const node = walker.currentNode;

node.nodeType === win.Node.ELEMENT_NODE ? indexElement(node) : indexComment(node);
}
}

if (manifest && doc.body) indexDocument();

return {
entriesFor(path) {
return byPath.get(path) ?? [];
},

entryAt(x, y, target) {
for (let element = target; element; element = element.parentElement) {
const hits = (byElement.get(element) ?? []).filter(entry => isSelectable(entry) && entry.contains(x, y));

if (hits.length) return hits.sort((a, b) => b.depth - a.depth)[0];
}

return null;
},
};
}
61 changes: 61 additions & 0 deletions resources/js/components/ui/LivePreview/FieldScrolling.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
function scrollContainersOf(doc, element) {
const win = doc.defaultView;
const containers = [];

for (let el = element; el && el !== doc.body; el = el.parentElement) {
if (el.scrollHeight > el.clientHeight && /auto|scroll|hidden/.test(win.getComputedStyle(el).overflowY)) {
containers.push(el);
}
}

return containers;
}

function isInView(doc, entry, rect) {
const win = doc.defaultView;

if (rect.top < 0 || rect.bottom > win.innerHeight || rect.left < 0 || rect.right > win.innerWidth) return false;

return scrollContainersOf(doc, entry.element).every(container => {
const bounds = container.getBoundingClientRect();

return rect.top >= bounds.top && rect.bottom <= bounds.bottom;
});
}

function scrollEntryIntoView(doc, entry) {
const win = doc.defaultView;

for (const container of scrollContainersOf(doc, entry.element)) {
const rect = entry.rects()[0];
const bounds = container.getBoundingClientRect();

if (rect.top < bounds.top || rect.bottom > bounds.bottom) {
container.scrollTop += rect.top - bounds.top + rect.height / 2 - container.clientHeight / 2;
}
}

entry.element.scrollIntoView({ block: 'center', inline: 'nearest' });

const rect = entry.rects()[0];

if (rect && (rect.top < 0 || rect.bottom > win.innerHeight)) {
win.scrollBy(0, rect.top + rect.height / 2 - win.innerHeight / 2);
}
}

export function scrollFieldIntoView(doc, entries) {
const win = doc.defaultView;
const occurrences = entries.flatMap(entry => {
return entry.rects()
.filter(rect => rect.width && rect.height)
.map(rect => ({ entry, rect }));
});

if (!occurrences.length || occurrences.some(({ entry, rect }) => isInView(doc, entry, rect))) return;

const distanceFromCenter = ({ rect }) => Math.abs(rect.top - win.innerHeight / 2);
const nearest = occurrences.sort((a, b) => distanceFromCenter(a) - distanceFromCenter(b))[0];

scrollEntryIntoView(doc, nearest.entry);
}
Loading
Loading