page-analyzer 1.1.1 → 1.2.0

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));
@@ -630,7 +690,7 @@
630
690
  }
631
691
 
632
692
  function getShot(block, index) {
633
- const direct = Array.isArray(block.blockScreenshotPaths) ? block.blockScreenshotPaths[0] : '';
693
+ const direct = asArray(block.blockScreenshotPaths)[0] || block.blockScreenshotPath || block.screenshotPath || '';
634
694
  if (direct) return { path: direct };
635
695
  return screenshotByBlockIdx.get(index) || null;
636
696
  }
@@ -651,8 +711,9 @@
651
711
  block.blockDescription,
652
712
  block.blockCssPath,
653
713
  block.blockIdxs,
654
- ...(block.blockSemantics || []),
655
- ...(block.blockPossibleEvents || [])
714
+ ...asArray(block.blockSemantics),
715
+ ...asArray(block.blockPossibleEvents),
716
+ ...asArray(block.blockSemanticGroups).map((item) => JSON.stringify(item))
656
717
  ].join(' ').toLowerCase();
657
718
  return haystack.includes(query.toLowerCase());
658
719
  }
@@ -670,11 +731,24 @@
670
731
  .filter(({ block, index }) => textMatches(block, index) && isVisibleByFilter(block, index));
671
732
  }
672
733
 
734
+ function screenshotCount() {
735
+ const paths = new Set();
736
+ for (const item of screenshotRows) {
737
+ if (item?.path) paths.add(item.path);
738
+ }
739
+ for (const block of blocks) {
740
+ for (const path of asArray(block.blockScreenshotPaths)) {
741
+ if (path) paths.add(path);
742
+ }
743
+ }
744
+ return paths.size;
745
+ }
746
+
673
747
  function renderMetrics() {
674
748
  const stats = data.analysis?.block_analysis?.stats || {};
675
749
  const metrics = [
676
750
  ['Blocks', blocks.length],
677
- ['Screenshots', screenshotRows.length],
751
+ ['Screenshots', screenshotCount()],
678
752
  ['Elements', data.parseMetrics?.elementsCount || 0],
679
753
  ['Parse ms', data.parseMetrics?.parseMs || 0]
680
754
  ];
@@ -690,6 +764,10 @@
690
764
 
691
765
  function renderList() {
692
766
  const rows = visibleBlocks();
767
+ if (!rows.length) {
768
+ els.list.innerHTML = '<div class="load-state">No blocks match the current search or filter.</div>';
769
+ return;
770
+ }
693
771
  els.list.innerHTML = rows.map(({ block, index }) => {
694
772
  const hasShot = Boolean(getShot(block, index));
695
773
  const status = hasShot ? 'shot' : 'no shot';
@@ -707,7 +785,12 @@
707
785
  }
708
786
 
709
787
  function renderAllBlocks() {
710
- els.allBlocks.innerHTML = visibleBlocks().map(({ block, index }) => {
788
+ const rows = visibleBlocks();
789
+ if (!rows.length) {
790
+ els.allBlocks.innerHTML = '<div class="missing-shot">No blocks to show.</div>';
791
+ return;
792
+ }
793
+ els.allBlocks.innerHTML = rows.map(({ block, index }) => {
711
794
  const shot = getShot(block, index);
712
795
  const image = shot
713
796
  ? '<img src="' + pathToUrl(shot.path) + '" alt="Screenshot for block ' + index + '">'
@@ -728,6 +811,18 @@
728
811
  }
729
812
 
730
813
  function renderSelected() {
814
+ if (!blocks.length) {
815
+ els.selectedTitle.textContent = 'No blocks found';
816
+ els.selectedDescription.textContent = 'Loaded JSON does not contain block analysis rows.';
817
+ els.copySelector.disabled = true;
818
+ els.fullPageLink.href = pathToUrl(data.screenshots?.fullPage || '');
819
+ els.fullPageLink.style.display = data.screenshots?.fullPage ? 'inline-flex' : 'none';
820
+ els.screenshot.innerHTML = '<div class="missing-shot">Load a Page Analyzer result with analysis.block_analysis.blocks.</div>';
821
+ els.info.innerHTML = '';
822
+ els.raw.textContent = JSON.stringify(data, null, 2);
823
+ return;
824
+ }
825
+ if (!blocks[selectedIndex]) selectedIndex = 0;
731
826
  const block = blocks[selectedIndex] || {};
732
827
  const shot = getShot(block, selectedIndex);
733
828
  els.selectedTitle.textContent = '#' + selectedIndex + ' ' + (block.blockName || 'Unnamed block');
@@ -745,9 +840,9 @@
745
840
  '<div class="missing-shot">No screenshot was generated for this block.<br>Most likely the selector was empty, hidden, or not screenshotable.</div>';
746
841
  }
747
842
 
748
- const semantics = block.blockSemantics || [];
749
- const events = block.blockPossibleEvents || [];
750
- const groups = block.blockSemanticGroups || [];
843
+ const semantics = asArray(block.blockSemantics);
844
+ const events = asArray(block.blockPossibleEvents);
845
+ const groups = asArray(block.blockSemanticGroups);
751
846
  els.info.innerHTML = [
752
847
  info('Block name', block.blockName || 'n/a'),
753
848
  info('Block idxs', block.blockIdxs || String(selectedIndex)),
@@ -823,44 +918,132 @@
823
918
  els.loadState.classList.toggle('error', isError);
824
919
  }
825
920
 
826
- function initialize(nextData, sourceLabel) {
921
+ function getBlocks(nextData) {
922
+ const candidates = [
923
+ nextData?.analysis?.block_analysis?.blocks,
924
+ nextData?.block_analysis?.blocks,
925
+ nextData?.analysis?.blocks,
926
+ nextData?.blocks
927
+ ];
928
+ return candidates.find(Array.isArray) || [];
929
+ }
930
+
931
+ function getScreenshotRows(nextData) {
932
+ const rows = nextData?.screenshots?.blocks || nextData?.blockScreenshots || [];
933
+ if (Array.isArray(rows)) {
934
+ return rows
935
+ .map((item, index) => typeof item === 'string' ? { blockIdx: index, path: item } : item)
936
+ .filter((item) => item && item.path);
937
+ }
938
+ if (rows && typeof rows === 'object') {
939
+ return Object.entries(rows).map(([blockIdx, path]) => ({ blockIdx, path }));
940
+ }
941
+ return [];
942
+ }
943
+
944
+ function buildScreenshotMap(rows) {
945
+ return new Map(rows.map((item, index) => [
946
+ Number(item.blockIdx ?? item.blockIndex ?? item.index ?? index),
947
+ item
948
+ ]));
949
+ }
950
+
951
+ function sourceLabelForUrl(value) {
952
+ try {
953
+ const url = new URL(value, window.location.href);
954
+ return url.pathname.split('/').filter(Boolean).pop() || url.host || value;
955
+ } catch {
956
+ return value;
957
+ }
958
+ }
959
+
960
+ function normalizeResultUrl(value) {
961
+ const text = String(value || '').trim();
962
+ if (!text) {
963
+ throw new Error('Result JSON URL is empty');
964
+ }
965
+ return new URL(text, window.location.href).href;
966
+ }
967
+
968
+ function getInitialResultUrl() {
969
+ const params = new URLSearchParams(window.location.search);
970
+ return params.get('result') || params.get('url') || '';
971
+ }
972
+
973
+ function rememberResultUrl(value) {
974
+ const url = new URL(window.location.href);
975
+ url.searchParams.set('result', value);
976
+ url.searchParams.delete('url');
977
+ window.history.replaceState(null, '', url);
978
+ }
979
+
980
+ function forgetResultUrl() {
981
+ const url = new URL(window.location.href);
982
+ url.searchParams.delete('result');
983
+ url.searchParams.delete('url');
984
+ window.history.replaceState(null, '', url);
985
+ }
986
+
987
+ function renderLoadError(error, title = 'result.json not loaded') {
988
+ if (data) return;
989
+ els.selectedTitle.textContent = title;
990
+ els.selectedDescription.textContent = error.message || 'Unknown load error';
991
+ els.screenshot.innerHTML = '<div class="missing-shot">Load result.json to inspect blocks.</div>';
992
+ els.info.innerHTML = '';
993
+ els.raw.textContent = '';
994
+ els.allBlocks.innerHTML = '';
995
+ els.list.innerHTML = '';
996
+ els.metrics.innerHTML = '';
997
+ }
998
+
999
+ function initialize(nextData, sourceLabel, sourceUrl = '') {
827
1000
  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]));
1001
+ resultSourceUrl = sourceUrl ? normalizeResultUrl(sourceUrl) : '';
1002
+ blocks = getBlocks(data);
1003
+ screenshotRows = getScreenshotRows(data);
1004
+ screenshotByBlockIdx = buildScreenshotMap(screenshotRows);
831
1005
  selectedIndex = 0;
832
1006
  query = '';
833
1007
  els.search.value = '';
834
1008
  els.pageTitle.textContent = data.title || 'Untitled page';
835
- setLoadState(sourceLabel + ' loaded. ' + blocks.length + ' blocks, ' + screenshotRows.length + ' screenshots.');
836
- els.fileLoader.hidden = true;
1009
+ setLoadState(sourceLabel + ' loaded. ' + blocks.length + ' blocks, ' + screenshotCount() + ' screenshots.');
837
1010
  renderMetrics();
838
1011
  renderList();
839
1012
  renderAllBlocks();
840
1013
  renderSelected();
841
1014
  }
842
1015
 
843
- async function loadResultJson() {
1016
+ async function loadResultFromUrl(rawUrl, updateAddress = false) {
1017
+ const resolvedUrl = normalizeResultUrl(rawUrl);
1018
+ els.resultUrl.value = rawUrl;
1019
+ setLoadState('Loading ' + rawUrl + '...');
844
1020
  try {
845
- const response = await fetch('./result.json', { cache: 'no-store' });
1021
+ const response = await fetch(resolvedUrl, { cache: 'no-store' });
846
1022
  if (!response.ok) {
847
- throw new Error('HTTP ' + response.status + ' while loading result.json');
1023
+ throw new Error('HTTP ' + response.status + ' while loading result JSON');
848
1024
  }
849
- initialize(await response.json(), 'result.json');
1025
+ initialize(await response.json(), sourceLabelForUrl(rawUrl), resolvedUrl);
1026
+ if (updateAddress) rememberResultUrl(rawUrl);
850
1027
  } catch (error) {
851
1028
  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.',
1029
+ 'Could not load ' + rawUrl + '. ' + (error.message || 'Unknown load error') + '. Remote URLs must allow browser CORS access.',
853
1030
  true
854
1031
  );
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 = '';
1032
+ renderLoadError(error);
861
1033
  }
862
1034
  }
863
1035
 
1036
+ async function loadResultJson() {
1037
+ const initialUrl = getInitialResultUrl() || DEFAULT_RESULT_URL;
1038
+ els.resultUrl.value = initialUrl;
1039
+ await loadResultFromUrl(initialUrl);
1040
+ }
1041
+
1042
+ els.urlLoader.addEventListener('submit', async (event) => {
1043
+ event.preventDefault();
1044
+ await loadResultFromUrl(els.resultUrl.value, true);
1045
+ });
1046
+
864
1047
  els.resultFile.addEventListener('change', async () => {
865
1048
  const file = els.resultFile.files?.[0];
866
1049
  if (!file) {
@@ -868,6 +1051,8 @@
868
1051
  }
869
1052
  try {
870
1053
  initialize(JSON.parse(await file.text()), file.name);
1054
+ els.resultUrl.value = '';
1055
+ forgetResultUrl();
871
1056
  } catch (error) {
872
1057
  setLoadState('Could not parse selected JSON: ' + error.message, true);
873
1058
  }