page-analyzer 1.1.1 → 1.2.1

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.
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <link rel="icon" href="data:,">
6
7
  <title>Page Analyzer Result Viewer</title>
7
8
  <style>
8
9
  :root {
@@ -108,12 +109,28 @@
108
109
  color: var(--missing);
109
110
  }
110
111
 
112
+ .url-loader,
111
113
  .file-loader {
112
114
  display: grid;
113
115
  gap: 8px;
114
116
  margin-top: 8px;
115
117
  }
116
118
 
119
+ .url-loader label,
120
+ .file-loader span {
121
+ color: var(--muted);
122
+ font-size: 11px;
123
+ font-weight: 800;
124
+ text-transform: uppercase;
125
+ }
126
+
127
+ .url-row {
128
+ display: grid;
129
+ grid-template-columns: minmax(0, 1fr) auto;
130
+ gap: 8px;
131
+ }
132
+
133
+ .url-loader input,
117
134
  .file-loader input {
118
135
  width: 100%;
119
136
  border: 1px solid var(--line);
@@ -123,6 +140,16 @@
123
140
  font-size: 12px;
124
141
  }
125
142
 
143
+ .url-loader button {
144
+ min-width: 68px;
145
+ border: 1px solid var(--ink);
146
+ background: var(--ink);
147
+ color: white;
148
+ padding: 0 12px;
149
+ cursor: pointer;
150
+ font-size: 12px;
151
+ }
152
+
126
153
  .metrics {
127
154
  display: grid;
128
155
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -522,7 +549,14 @@
522
549
  <h1>Block Review</h1>
523
550
  <div class="title" id="page-title"></div>
524
551
  <div class="load-state" id="load-state">Loading result.json...</div>
525
- <label class="file-loader" id="file-loader" hidden>
552
+ <form class="url-loader" id="url-loader">
553
+ <label for="result-url">Result JSON URL</label>
554
+ <div class="url-row">
555
+ <input id="result-url" type="text" inputmode="url" autocomplete="url" placeholder="https://example.com/result.json">
556
+ <button type="submit">Load</button>
557
+ </div>
558
+ </form>
559
+ <label class="file-loader" id="file-loader">
526
560
  <span>Choose result.json manually</span>
527
561
  <input id="result-file" type="file" accept="application/json,.json">
528
562
  </label>
@@ -598,6 +632,9 @@
598
632
  let selectedIndex = 0;
599
633
  let activeFilter = 'all';
600
634
  let query = '';
635
+ let resultSourceUrl = '';
636
+
637
+ const DEFAULT_RESULT_URL = './result.json';
601
638
 
602
639
  const els = {
603
640
  pageTitle: document.getElementById('page-title'),
@@ -614,13 +651,36 @@
614
651
  fullPageLink: document.getElementById('full-page-link'),
615
652
  loadState: document.getElementById('load-state'),
616
653
  fileLoader: document.getElementById('file-loader'),
617
- resultFile: document.getElementById('result-file')
654
+ resultFile: document.getElementById('result-file'),
655
+ urlLoader: document.getElementById('url-loader'),
656
+ resultUrl: document.getElementById('result-url')
618
657
  };
619
658
 
659
+ function asArray(value) {
660
+ if (Array.isArray(value)) return value;
661
+ if (value === undefined || value === null || value === '') return [];
662
+ return [value];
663
+ }
664
+
665
+ function isLikelyLocalAbsolutePath(value) {
666
+ return /^\/(Users|Volumes|Applications|System|Library|private|tmp|var|home)\//.test(value);
667
+ }
668
+
620
669
  function pathToUrl(value) {
621
- const text = String(value || '');
670
+ const text = String(value || '').trim();
622
671
  if (!text) return '';
623
- if (/^(https?:|file:|data:)/i.test(text)) return text;
672
+ if (/^(https?:|file:|data:|blob:)/i.test(text)) return text;
673
+ if (resultSourceUrl && /^(https?:|file:)/i.test(resultSourceUrl)) {
674
+ const shouldResolveFromSource = !text.startsWith('/') ||
675
+ (/^https?:/i.test(resultSourceUrl) && !isLikelyLocalAbsolutePath(text));
676
+ if (shouldResolveFromSource) {
677
+ try {
678
+ return new URL(text, resultSourceUrl).href;
679
+ } catch {
680
+ // Fall through to local snapshot handling.
681
+ }
682
+ }
683
+ }
624
684
  const snapshotIndex = text.lastIndexOf('/snapshots/');
625
685
  if (snapshotIndex >= 0) {
626
686
  return encodeURI('./snapshots/' + text.slice(snapshotIndex + '/snapshots/'.length));
@@ -629,8 +689,21 @@
629
689
  return text;
630
690
  }
631
691
 
692
+ function safeResourceUrl(value) {
693
+ const url = pathToUrl(value);
694
+ if (!url) return '';
695
+ if (/^(https?:|file:|blob:)/i.test(url)) return url;
696
+ if (/^data:image\/(png|jpe?g|gif|webp);/i.test(url)) return url;
697
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) return url;
698
+ return '';
699
+ }
700
+
701
+ function imageSrcAttr(value) {
702
+ return escapeHtml(safeResourceUrl(value));
703
+ }
704
+
632
705
  function getShot(block, index) {
633
- const direct = Array.isArray(block.blockScreenshotPaths) ? block.blockScreenshotPaths[0] : '';
706
+ const direct = asArray(block.blockScreenshotPaths)[0] || block.blockScreenshotPath || block.screenshotPath || '';
634
707
  if (direct) return { path: direct };
635
708
  return screenshotByBlockIdx.get(index) || null;
636
709
  }
@@ -651,8 +724,9 @@
651
724
  block.blockDescription,
652
725
  block.blockCssPath,
653
726
  block.blockIdxs,
654
- ...(block.blockSemantics || []),
655
- ...(block.blockPossibleEvents || [])
727
+ ...asArray(block.blockSemantics),
728
+ ...asArray(block.blockPossibleEvents),
729
+ ...asArray(block.blockSemanticGroups).map((item) => JSON.stringify(item))
656
730
  ].join(' ').toLowerCase();
657
731
  return haystack.includes(query.toLowerCase());
658
732
  }
@@ -670,11 +744,24 @@
670
744
  .filter(({ block, index }) => textMatches(block, index) && isVisibleByFilter(block, index));
671
745
  }
672
746
 
747
+ function screenshotCount() {
748
+ const paths = new Set();
749
+ for (const item of screenshotRows) {
750
+ if (item?.path) paths.add(item.path);
751
+ }
752
+ for (const block of blocks) {
753
+ for (const path of asArray(block.blockScreenshotPaths)) {
754
+ if (path) paths.add(path);
755
+ }
756
+ }
757
+ return paths.size;
758
+ }
759
+
673
760
  function renderMetrics() {
674
761
  const stats = data.analysis?.block_analysis?.stats || {};
675
762
  const metrics = [
676
763
  ['Blocks', blocks.length],
677
- ['Screenshots', screenshotRows.length],
764
+ ['Screenshots', screenshotCount()],
678
765
  ['Elements', data.parseMetrics?.elementsCount || 0],
679
766
  ['Parse ms', data.parseMetrics?.parseMs || 0]
680
767
  ];
@@ -690,6 +777,10 @@
690
777
 
691
778
  function renderList() {
692
779
  const rows = visibleBlocks();
780
+ if (!rows.length) {
781
+ els.list.innerHTML = '<div class="load-state">No blocks match the current search or filter.</div>';
782
+ return;
783
+ }
693
784
  els.list.innerHTML = rows.map(({ block, index }) => {
694
785
  const hasShot = Boolean(getShot(block, index));
695
786
  const status = hasShot ? 'shot' : 'no shot';
@@ -707,10 +798,16 @@
707
798
  }
708
799
 
709
800
  function renderAllBlocks() {
710
- els.allBlocks.innerHTML = visibleBlocks().map(({ block, index }) => {
801
+ const rows = visibleBlocks();
802
+ if (!rows.length) {
803
+ els.allBlocks.innerHTML = '<div class="missing-shot">No blocks to show.</div>';
804
+ return;
805
+ }
806
+ els.allBlocks.innerHTML = rows.map(({ block, index }) => {
711
807
  const shot = getShot(block, index);
712
- const image = shot
713
- ? '<img src="' + pathToUrl(shot.path) + '" alt="Screenshot for block ' + index + '">'
808
+ const shotUrl = shot?.path ? imageSrcAttr(shot.path) : '';
809
+ const image = shotUrl
810
+ ? '<img src="' + shotUrl + '" alt="Screenshot for block ' + index + '">'
714
811
  : '<div class="empty-thumb">No selector screenshot</div>';
715
812
  return '<article class="mini" data-index="' + index + '">' +
716
813
  image +
@@ -728,26 +825,41 @@
728
825
  }
729
826
 
730
827
  function renderSelected() {
828
+ if (!blocks.length) {
829
+ els.selectedTitle.textContent = 'No blocks found';
830
+ els.selectedDescription.textContent = 'Loaded JSON does not contain block analysis rows.';
831
+ els.copySelector.disabled = true;
832
+ const fullPageUrl = safeResourceUrl(data.screenshots?.fullPage || '');
833
+ els.fullPageLink.href = fullPageUrl;
834
+ els.fullPageLink.style.display = fullPageUrl ? 'inline-flex' : 'none';
835
+ els.screenshot.innerHTML = '<div class="missing-shot">Load a Page Analyzer result with analysis.block_analysis.blocks.</div>';
836
+ els.info.innerHTML = '';
837
+ els.raw.textContent = JSON.stringify(data, null, 2);
838
+ return;
839
+ }
840
+ if (!blocks[selectedIndex]) selectedIndex = 0;
731
841
  const block = blocks[selectedIndex] || {};
732
842
  const shot = getShot(block, selectedIndex);
733
843
  els.selectedTitle.textContent = '#' + selectedIndex + ' ' + (block.blockName || 'Unnamed block');
734
844
  els.selectedDescription.textContent = block.blockDescription || 'No description available.';
735
845
  els.copySelector.disabled = !block.blockCssPath;
736
- els.fullPageLink.href = pathToUrl(data.screenshots?.fullPage || '');
737
- els.fullPageLink.style.display = data.screenshots?.fullPage ? 'inline-flex' : 'none';
846
+ const fullPageUrl = safeResourceUrl(data.screenshots?.fullPage || '');
847
+ els.fullPageLink.href = fullPageUrl;
848
+ els.fullPageLink.style.display = fullPageUrl ? 'inline-flex' : 'none';
738
849
 
739
- if (shot?.path) {
850
+ const shotUrl = shot?.path ? imageSrcAttr(shot.path) : '';
851
+ if (shotUrl) {
740
852
  els.screenshot.innerHTML =
741
- '<div class="screenshot-frame"><img src="' + pathToUrl(shot.path) + '" alt="Screenshot for selected block"></div>' +
853
+ '<div class="screenshot-frame"><img src="' + shotUrl + '" alt="Screenshot for selected block"></div>' +
742
854
  '<div class="info wide"><label>Screenshot path</label><span>' + escapeHtml(shot.path) + '</span></div>';
743
855
  } else {
744
856
  els.screenshot.innerHTML =
745
857
  '<div class="missing-shot">No screenshot was generated for this block.<br>Most likely the selector was empty, hidden, or not screenshotable.</div>';
746
858
  }
747
859
 
748
- const semantics = block.blockSemantics || [];
749
- const events = block.blockPossibleEvents || [];
750
- const groups = block.blockSemanticGroups || [];
860
+ const semantics = asArray(block.blockSemantics);
861
+ const events = asArray(block.blockPossibleEvents);
862
+ const groups = asArray(block.blockSemanticGroups);
751
863
  els.info.innerHTML = [
752
864
  info('Block name', block.blockName || 'n/a'),
753
865
  info('Block idxs', block.blockIdxs || String(selectedIndex)),
@@ -823,44 +935,132 @@
823
935
  els.loadState.classList.toggle('error', isError);
824
936
  }
825
937
 
826
- function initialize(nextData, sourceLabel) {
938
+ function getBlocks(nextData) {
939
+ const candidates = [
940
+ nextData?.analysis?.block_analysis?.blocks,
941
+ nextData?.block_analysis?.blocks,
942
+ nextData?.analysis?.blocks,
943
+ nextData?.blocks
944
+ ];
945
+ return candidates.find(Array.isArray) || [];
946
+ }
947
+
948
+ function getScreenshotRows(nextData) {
949
+ const rows = nextData?.screenshots?.blocks || nextData?.blockScreenshots || [];
950
+ if (Array.isArray(rows)) {
951
+ return rows
952
+ .map((item, index) => typeof item === 'string' ? { blockIdx: index, path: item } : item)
953
+ .filter((item) => item && item.path);
954
+ }
955
+ if (rows && typeof rows === 'object') {
956
+ return Object.entries(rows).map(([blockIdx, path]) => ({ blockIdx, path }));
957
+ }
958
+ return [];
959
+ }
960
+
961
+ function buildScreenshotMap(rows) {
962
+ return new Map(rows.map((item, index) => [
963
+ Number(item.blockIdx ?? item.blockIndex ?? item.index ?? index),
964
+ item
965
+ ]));
966
+ }
967
+
968
+ function sourceLabelForUrl(value) {
969
+ try {
970
+ const url = new URL(value, window.location.href);
971
+ return url.pathname.split('/').filter(Boolean).pop() || url.host || value;
972
+ } catch {
973
+ return value;
974
+ }
975
+ }
976
+
977
+ function normalizeResultUrl(value) {
978
+ const text = String(value || '').trim();
979
+ if (!text) {
980
+ throw new Error('Result JSON URL is empty');
981
+ }
982
+ return new URL(text, window.location.href).href;
983
+ }
984
+
985
+ function getInitialResultUrl() {
986
+ const params = new URLSearchParams(window.location.search);
987
+ return params.get('result') || params.get('url') || '';
988
+ }
989
+
990
+ function rememberResultUrl(value) {
991
+ const url = new URL(window.location.href);
992
+ url.searchParams.set('result', value);
993
+ url.searchParams.delete('url');
994
+ window.history.replaceState(null, '', url);
995
+ }
996
+
997
+ function forgetResultUrl() {
998
+ const url = new URL(window.location.href);
999
+ url.searchParams.delete('result');
1000
+ url.searchParams.delete('url');
1001
+ window.history.replaceState(null, '', url);
1002
+ }
1003
+
1004
+ function renderLoadError(error, title = 'result.json not loaded') {
1005
+ if (data) return;
1006
+ els.selectedTitle.textContent = title;
1007
+ els.selectedDescription.textContent = error.message || 'Unknown load error';
1008
+ els.screenshot.innerHTML = '<div class="missing-shot">Load result.json to inspect blocks.</div>';
1009
+ els.info.innerHTML = '';
1010
+ els.raw.textContent = '';
1011
+ els.allBlocks.innerHTML = '';
1012
+ els.list.innerHTML = '';
1013
+ els.metrics.innerHTML = '';
1014
+ }
1015
+
1016
+ function initialize(nextData, sourceLabel, sourceUrl = '') {
827
1017
  data = nextData || {};
828
- blocks = data.analysis?.block_analysis?.blocks || [];
829
- screenshotRows = data.screenshots?.blocks || [];
830
- screenshotByBlockIdx = new Map(screenshotRows.map((item) => [Number(item.blockIdx), item]));
1018
+ resultSourceUrl = sourceUrl ? normalizeResultUrl(sourceUrl) : '';
1019
+ blocks = getBlocks(data);
1020
+ screenshotRows = getScreenshotRows(data);
1021
+ screenshotByBlockIdx = buildScreenshotMap(screenshotRows);
831
1022
  selectedIndex = 0;
832
1023
  query = '';
833
1024
  els.search.value = '';
834
1025
  els.pageTitle.textContent = data.title || 'Untitled page';
835
- setLoadState(sourceLabel + ' loaded. ' + blocks.length + ' blocks, ' + screenshotRows.length + ' screenshots.');
836
- els.fileLoader.hidden = true;
1026
+ setLoadState(sourceLabel + ' loaded. ' + blocks.length + ' blocks, ' + screenshotCount() + ' screenshots.');
837
1027
  renderMetrics();
838
1028
  renderList();
839
1029
  renderAllBlocks();
840
1030
  renderSelected();
841
1031
  }
842
1032
 
843
- async function loadResultJson() {
1033
+ async function loadResultFromUrl(rawUrl, updateAddress = false) {
1034
+ const resolvedUrl = normalizeResultUrl(rawUrl);
1035
+ els.resultUrl.value = rawUrl;
1036
+ setLoadState('Loading ' + rawUrl + '...');
844
1037
  try {
845
- const response = await fetch('./result.json', { cache: 'no-store' });
1038
+ const response = await fetch(resolvedUrl, { cache: 'no-store' });
846
1039
  if (!response.ok) {
847
- throw new Error('HTTP ' + response.status + ' while loading result.json');
1040
+ throw new Error('HTTP ' + response.status + ' while loading result JSON');
848
1041
  }
849
- initialize(await response.json(), 'result.json');
1042
+ initialize(await response.json(), sourceLabelForUrl(rawUrl), resolvedUrl);
1043
+ if (updateAddress) rememberResultUrl(rawUrl);
850
1044
  } catch (error) {
851
1045
  setLoadState(
852
- 'Could not load ./result.json automatically. If this page is opened with file://, your browser may block local JSON reads. Use the picker below or serve this folder locally.',
1046
+ 'Could not load ' + rawUrl + '. ' + (error.message || 'Unknown load error') + '. Remote URLs must allow browser CORS access.',
853
1047
  true
854
1048
  );
855
- els.fileLoader.hidden = false;
856
- els.selectedTitle.textContent = 'result.json not loaded';
857
- els.selectedDescription.textContent = error.message || 'Unknown load error';
858
- els.screenshot.innerHTML = '<div class="missing-shot">Load result.json to inspect blocks.</div>';
859
- els.info.innerHTML = '';
860
- els.raw.textContent = '';
1049
+ renderLoadError(error);
861
1050
  }
862
1051
  }
863
1052
 
1053
+ async function loadResultJson() {
1054
+ const initialUrl = getInitialResultUrl() || DEFAULT_RESULT_URL;
1055
+ els.resultUrl.value = initialUrl;
1056
+ await loadResultFromUrl(initialUrl);
1057
+ }
1058
+
1059
+ els.urlLoader.addEventListener('submit', async (event) => {
1060
+ event.preventDefault();
1061
+ await loadResultFromUrl(els.resultUrl.value, true);
1062
+ });
1063
+
864
1064
  els.resultFile.addEventListener('change', async () => {
865
1065
  const file = els.resultFile.files?.[0];
866
1066
  if (!file) {
@@ -868,6 +1068,8 @@
868
1068
  }
869
1069
  try {
870
1070
  initialize(JSON.parse(await file.text()), file.name);
1071
+ els.resultUrl.value = '';
1072
+ forgetResultUrl();
871
1073
  } catch (error) {
872
1074
  setLoadState('Could not parse selected JSON: ' + error.message, true);
873
1075
  }