@indiscale/linkahead-webui-ext-map 0.5.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.
Files changed (76) hide show
  1. package/.eslintrc.json +45 -0
  2. package/.gitlab-ci.yml +44 -0
  3. package/CHANGELOG.md +78 -0
  4. package/README.md +97 -0
  5. package/RELEASE_GUIDELINES.md +45 -0
  6. package/__mocks__/fileMock.js +3 -0
  7. package/__mocks__/styleMock.js +1 -0
  8. package/babel.config.js +22 -0
  9. package/cypress/e2e/standalone-map.cy.js +55 -0
  10. package/cypress/support/commands.js +25 -0
  11. package/cypress/support/e2e.js +17 -0
  12. package/cypress.config.js +10 -0
  13. package/dist/2b3e1faf89f94a483539.png +0 -0
  14. package/dist/416d91365b44e4b4f477.png +0 -0
  15. package/dist/8f2c4d11474275fbc161.png +0 -0
  16. package/dist/index.html +1 -0
  17. package/dist/linkahead-webui-ext-map.js +3 -0
  18. package/dist/linkahead-webui-ext-map.js.LICENSE.txt +45 -0
  19. package/dist/linkahead-webui-ext-map.js.map +1 -0
  20. package/iframe/index.html +6 -0
  21. package/indiscale-linkahead-webui-ext-map-0.4.1.tgz +0 -0
  22. package/jest.config.js +23 -0
  23. package/jest.setup.js +2 -0
  24. package/package.json +105 -0
  25. package/public/favicon.ico +0 -0
  26. package/public/index.html +11 -0
  27. package/public/logo192.png +0 -0
  28. package/public/logo512.png +0 -0
  29. package/public/manifest.json +25 -0
  30. package/public/map_tile_caosdb_logo.png +0 -0
  31. package/public/mock.js +41 -0
  32. package/public/robots.txt +3 -0
  33. package/select_query.json +3 -0
  34. package/src/AllMapEntities.tsx +294 -0
  35. package/src/CurrentPageEntities.js +318 -0
  36. package/src/Map.helpers.css +8 -0
  37. package/src/Map.helpers.js +536 -0
  38. package/src/Map.js +288 -0
  39. package/src/Map.test.js +252 -0
  40. package/src/MapConfig.js +75 -0
  41. package/src/__snapshots__/Map.test.js.snap +1725 -0
  42. package/src/components/Coordinates.js +24 -0
  43. package/src/components/ErrorComponent.tsx +2 -0
  44. package/src/components/Graticule.js +27 -0
  45. package/src/components/Loader.module.css +17 -0
  46. package/src/components/Loader.tsx +36 -0
  47. package/src/components/PathDropDown.js +108 -0
  48. package/src/components/SearchControl.js +502 -0
  49. package/src/components/ToggleMapButton.js +194 -0
  50. package/src/components/ViewChangeControl.js +104 -0
  51. package/src/constants/index.js +1 -0
  52. package/src/context/ConfigProvider.test.js +232 -0
  53. package/src/context/ConfigProvider.tsx +189 -0
  54. package/src/context/LoadingProvider.test.js +124 -0
  55. package/src/context/LoadingProvider.tsx +117 -0
  56. package/src/context/PathIdProvider.js +102 -0
  57. package/src/contrib/latlnggraticule/LICENSE +20 -0
  58. package/src/contrib/latlnggraticule/README.md +68 -0
  59. package/src/contrib/latlnggraticule/leaflet.latlng-graticule.js +528 -0
  60. package/src/contrib/simplegraticule/L.Graticule.js +138 -0
  61. package/src/default_config.json +57 -0
  62. package/src/global.d.ts +8 -0
  63. package/src/index.js +6 -0
  64. package/src/index.scss +133 -0
  65. package/src/logging.js +7 -0
  66. package/src/renderHtmlTemplate.test.js +60 -0
  67. package/src/select-search.min.svg +1 -0
  68. package/src/select-search.svg +46 -0
  69. package/src/setupTests.js +5 -0
  70. package/src/utils/GenerateQueryString.js +200 -0
  71. package/src/utils/GenerateQueryString.test.js +304 -0
  72. package/src/utils/index.ts +3 -0
  73. package/standalone.config.js +5 -0
  74. package/static/map_tile_caosdb_logo.png +0 -0
  75. package/tsconfig.json +25 -0
  76. package/webpack.config.js +193 -0
@@ -0,0 +1,57 @@
1
+ {
2
+ "version": "0.5.0",
3
+ "default_view": "UNCONFIGURED",
4
+ "datamodel": {
5
+ "lat": "latitude",
6
+ "lng": "longitude"
7
+ },
8
+ "select": {
9
+ "query": {
10
+ "role": "RECORD",
11
+ "entity": ""
12
+ },
13
+ "paths": {
14
+ "PathObject": ["PathObject", "MapObject"]
15
+ }
16
+ },
17
+ "entityLayers": {
18
+ "current_page_entities": {
19
+ "active": true,
20
+ "name": "Entities on the current page.",
21
+ "description": "Show all entities on the current page.",
22
+ "icon_options": {
23
+ "html": "<i class=\"bi-geo-alt-fill\" style=\"font-size: 20px; color: #00F;\"></i>",
24
+ "iconAnchor": [10, 19],
25
+ "className": ""
26
+ },
27
+ "zIndexOffset": 1000
28
+ },
29
+ "all_map_entities": {
30
+ "active": true,
31
+ "name": "All entities",
32
+ "description": "Show all entities with coordinates.",
33
+ "icon_options": {
34
+ "html": "<i class=\"bi-geo-alt-fill\" style=\"font-size: 20px; color: #F00;\"></i>",
35
+ "iconAnchor": [10, 19],
36
+ "className": ""
37
+ },
38
+ "zIndexOffset": 0
39
+ }
40
+ },
41
+ "views": [
42
+ {
43
+ "id": "UNCONFIGURED",
44
+ "zoom": 10,
45
+ "center": {
46
+ "lat": 0,
47
+ "lng": 0
48
+ },
49
+ "select": false,
50
+ "view_change": false,
51
+ "tileLayer": {
52
+ "type": "wms",
53
+ "url": "/map_tile_caosdb_logo.png"
54
+ }
55
+ }
56
+ ]
57
+ }
@@ -0,0 +1,8 @@
1
+ declare module "*.module.css" {
2
+ const classes: { readonly [key: string]: string };
3
+ export default classes;
4
+ }
5
+ declare module "*.css" {
6
+ const classes: { readonly [key: string]: string };
7
+ export default classes;
8
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import "./index.scss";
2
+ import "regenerator-runtime/runtime.js";
3
+ import { Map } from "./Map";
4
+ import { ToggleMapButton } from "./components/ToggleMapButton";
5
+
6
+ export { Map, ToggleMapButton };
package/src/index.scss ADDED
@@ -0,0 +1,133 @@
1
+ @import "leaflet/dist/leaflet.css";
2
+ @import "leaflet.coordinates/src/Control.Coordinates.css";
3
+
4
+ .map {
5
+ width: 100%;
6
+ height: 500px;
7
+ position: relative;
8
+
9
+ .viewMenu {
10
+ padding: 4px 10px;
11
+ text-align: left;
12
+ background-color: white;
13
+ position: absolute;
14
+ bottom: 0px;
15
+ left: 34px;
16
+ border: 1px solid rgba(0, 0, 0, 0.2);
17
+ border-radius: 4px;
18
+ min-width: 100px;
19
+ }
20
+
21
+ .leaflet-popup-content {
22
+ position: relative;
23
+
24
+ .caosdb-f-map-popup-entity-link {
25
+ position: absolute;
26
+ top: 0;
27
+ right: 0;
28
+ }
29
+ }
30
+
31
+ .leaflet-control.leaflet-control-layers {
32
+ height: 34px;
33
+ width: 34px;
34
+
35
+ &.leaflet-control-layers-expanded {
36
+ height: unset;
37
+ width: unset;
38
+
39
+ .leaflet-control-layers-toggle {
40
+ display: none;
41
+ }
42
+ }
43
+
44
+ .leaflet-control-layers-toggle {
45
+ display: flex;
46
+ justify-content: center;
47
+ align-items: center;
48
+ width: unset;
49
+ height: unset;
50
+ font-size: 20px;
51
+ color: #333;
52
+ text-decoration: none;
53
+ background-image: unset;
54
+ &::before {
55
+ display: inline-block;
56
+ font-family: bootstrap-icons !important;
57
+ font-style: normal;
58
+ font-weight: normal !important;
59
+ font-variant: normal;
60
+ text-transform: none;
61
+ content: "\F3E7";
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ .caosdb-f-map-change-view-btn,
68
+ .caosdb-f-map-search-btn,
69
+ .caosdb-f-map-select-search-btn {
70
+ background-color: white;
71
+ width: 34px;
72
+ height: 34px;
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ box-sizing: border-box;
77
+ border: 1px solid rgba(0, 0, 0, 0.2);
78
+ border-radius: 4px;
79
+ }
80
+
81
+ .caosdb-f-map-select-search-btn {
82
+ img {
83
+ width: 80%;
84
+ height: 80%;
85
+ }
86
+
87
+ & + .caosdb-f-map-search-btn {
88
+ margin-top: 2px;
89
+ }
90
+
91
+ &.highlight {
92
+ background-color: #90ee90;
93
+ }
94
+ }
95
+
96
+ .caosdb-f-map-search-btn {
97
+ background-color: #ff8700;
98
+
99
+ i {
100
+ font-size: 15px;
101
+ }
102
+ }
103
+
104
+ .caosdb-f-map-parent-badge {
105
+ color: #333;
106
+ border: 1px solid #333;
107
+ }
108
+
109
+ .caosdb-f-map-entity-name-label {
110
+ margin-top: 4px;
111
+ margin-bottom: 4px;
112
+ }
113
+
114
+ .caosdb-f-map-path-drop-down {
115
+ height: 34px;
116
+ background-color: white;
117
+ border: 1px solid rgba(0, 0, 0, 0.2);
118
+ border-radius: 4px;
119
+ }
120
+
121
+ .caosdb-f-map-change-view-btn {
122
+ i {
123
+ font-size: 20px;
124
+ }
125
+ }
126
+
127
+ .caosdb-f-map-view-select {
128
+ width: max-content;
129
+
130
+ label {
131
+ margin-left: 8px;
132
+ }
133
+ }
package/src/logging.js ADDED
@@ -0,0 +1,7 @@
1
+ import log from "loglevel";
2
+
3
+ const logger = log.getLogger("caosdb-webui-ext-map");
4
+ if (process.env.DEVELOPMENT_MODE) {
5
+ window.log = log;
6
+ }
7
+ export { logger };
@@ -0,0 +1,60 @@
1
+ // eslint-disable-next-line
2
+ import React from "react";
3
+ import { renderHtmlTemplate } from "./Map.helpers";
4
+
5
+ describe("renderHtmlTemplate", () => {
6
+ test("replaces {placeholders} with values from result (including keys with spaces)", () => {
7
+ const out = renderHtmlTemplate(
8
+ { format: "<b>{id}</b> - {Publication date}" },
9
+ { id: 123, "Publication date": "2024-01-01" }
10
+ );
11
+
12
+ expect(out).toBe("<b>123</b> - 2024-01-01");
13
+ });
14
+
15
+ test("uses missing when placeholder value is undefined or null", () => {
16
+ const out = renderHtmlTemplate(
17
+ { format: "A:{a} B:{b} C:{c}" },
18
+ { a: 1, b: null },
19
+ { missing: "-" }
20
+ );
21
+
22
+ expect(out).toBe("A:1 B:- C:-");
23
+ });
24
+
25
+ test('replaces "<br>" with <div class="linebreak"></div>', () => {
26
+ const out = renderHtmlTemplate({ format: "x<br>y" }, {});
27
+ expect(out).toBe('x<div class="linebreak"></div>y');
28
+ });
29
+
30
+ test("replaces <br/> and <br /> variants", () => {
31
+ const out = renderHtmlTemplate({ format: "x<br/>y<br />z" }, {});
32
+ expect(out).toBe(
33
+ 'x<div class="linebreak"></div>y<div class="linebreak"></div>z'
34
+ );
35
+ });
36
+
37
+ test("replaces <br></br> (and whitespace between tags)", () => {
38
+ const out = renderHtmlTemplate({ format: "x<br></br>y<br> </br>z" }, {});
39
+ expect(out).toBe(
40
+ 'x<div class="linebreak"></div>y<div class="linebreak"></div>z'
41
+ );
42
+ });
43
+
44
+ test("is case-insensitive for BR tags", () => {
45
+ const out = renderHtmlTemplate({ format: "x<BR>y<Br/>z" }, {});
46
+ expect(out).toBe(
47
+ 'x<div class="linebreak"></div>y<div class="linebreak"></div>z'
48
+ );
49
+ });
50
+
51
+ test("trims placeholder keys like { id }", () => {
52
+ const out = renderHtmlTemplate({ format: "ID:{ id }" }, { id: 7 });
53
+ expect(out).toBe("ID:7");
54
+ });
55
+
56
+ test("handles non-string format by String() conversion", () => {
57
+ const out = renderHtmlTemplate({ format: 42 }, {});
58
+ expect(out).toBe("42");
59
+ });
60
+ });
@@ -0,0 +1 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><!-- License: CC-BY 4.0, IndiScale GmbH and zwicon https://www.zwicon.com/ --><path d="M5.5 3a.5.5 0 0 1 0 1A1.5 1.5 0 0 0 4 5.5a.5.5 0 0 1-1 0A2.5 2.5 0 0 1 5.5 3zm3 1a.5.5 0 0 1 0-1h2a.5.5 0 0 1 0 1zm5 0a.5.5 0 0 1 0-1h2a.5.5 0 0 1 0 1zm-5 17a.5.5 0 0 1 0-1h2a.5.5 0 0 1 0 1zM3 8.5a.5.5 0 0 1 1 0v2a.5.5 0 0 1-1 0zm0 5a.5.5 0 0 1 1 0v2a.5.5 0 0 1-1 0zm0 5a.5.5 0 0 1 1 0A1.5 1.5 0 0 0 5.5 20a.5.5 0 0 1 0 1A2.5 2.5 0 0 1 3 18.5zm18-8a.5.5 0 0 1-1 0v-2a.5.5 0 0 1 1 0zm0-5a.5.5 0 0 1-1 0A1.5 1.5 0 0 0 18.5 4a.5.5 0 0 1 0-1A2.5 2.5 0 0 1 21 5.5z"/><path d="M19.362 17.904a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zm.258-3.844a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/></svg>
@@ -0,0 +1,46 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!-- License: CC-BY 4.0, IndiScale GmbH -->
3
+ <!-- Based on: zwicon: https://www.zwicon.com/ (CC-BY) -->
4
+
5
+ <svg
6
+ fill="#000000"
7
+ width="800px"
8
+ height="800px"
9
+ viewBox="0 0 24 24"
10
+ version="1.1"
11
+ id="svg6"
12
+ sodipodi:docname="select-search.svg"
13
+ inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
14
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
15
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
16
+ xmlns="http://www.w3.org/2000/svg"
17
+ xmlns:svg="http://www.w3.org/2000/svg">
18
+ <defs
19
+ id="defs10" />
20
+ <sodipodi:namedview
21
+ id="namedview8"
22
+ pagecolor="#ffffff"
23
+ bordercolor="#000000"
24
+ borderopacity="0.25"
25
+ inkscape:showpageshadow="2"
26
+ inkscape:pageopacity="0.0"
27
+ inkscape:pagecheckerboard="0"
28
+ inkscape:deskcolor="#d1d1d1"
29
+ showgrid="false"
30
+ inkscape:zoom="1.055"
31
+ inkscape:cx="335.54502"
32
+ inkscape:cy="400"
33
+ inkscape:window-width="1920"
34
+ inkscape:window-height="1017"
35
+ inkscape:window-x="0"
36
+ inkscape:window-y="27"
37
+ inkscape:window-maximized="1"
38
+ inkscape:current-layer="svg6" />
39
+ <path
40
+ d="M 5.5,3 C 5.7761424,3 6,3.2238576 6,3.5 6,3.7761424 5.7761424,4 5.5,4 4.6715729,4 4,4.6715729 4,5.5 4,5.7761424 3.7761424,6 3.5,6 3.2238576,6 3,5.7761424 3,5.5 3,4.1192881 4.1192881,3 5.5,3 Z m 3,1 C 8.2238576,4 8,3.7761424 8,3.5 8,3.2238576 8.2238576,3 8.5,3 h 2 C 10.776142,3 11,3.2238576 11,3.5 11,3.7761424 10.776142,4 10.5,4 Z m 5,0 C 13.223858,4 13,3.7761424 13,3.5 13,3.2238576 13.223858,3 13.5,3 h 2 C 15.776142,3 16,3.2238576 16,3.5 16,3.7761424 15.776142,4 15.5,4 Z m -5,17 C 8.2238576,21 8,20.776142 8,20.5 8,20.223858 8.2238576,20 8.5,20 h 2 c 0.276142,0 0.5,0.223858 0.5,0.5 0,0.276142 -0.223858,0.5 -0.5,0.5 z M 3,8.5 C 3,8.2238576 3.2238576,8 3.5,8 3.7761424,8 4,8.2238576 4,8.5 v 2 C 4,10.776142 3.7761424,11 3.5,11 3.2238576,11 3,10.776142 3,10.5 Z m 0,5 C 3,13.223858 3.2238576,13 3.5,13 3.7761424,13 4,13.223858 4,13.5 v 2 C 4,15.776142 3.7761424,16 3.5,16 3.2238576,16 3,15.776142 3,15.5 Z m 0,5 C 3,18.223858 3.2238576,18 3.5,18 3.7761424,18 4,18.223858 4,18.5 4,19.328427 4.6715729,20 5.5,20 5.7761424,20 6,20.223858 6,20.5 6,20.776142 5.7761424,21 5.5,21 4.1192881,21 3,19.880712 3,18.5 Z m 18,-8 C 21,10.776142 20.776142,11 20.5,11 20.223858,11 20,10.776142 20,10.5 v -2 C 20,8.2238576 20.223858,8 20.5,8 20.776142,8 21,8.2238576 21,8.5 Z m 0,-5 C 21,5.7761424 20.776142,6 20.5,6 20.223858,6 20,5.7761424 20,5.5 20,4.6715729 19.328427,4 18.5,4 18.223858,4 18,3.7761424 18,3.5 18,3.2238576 18.223858,3 18.5,3 19.880712,3 21,4.1192881 21,5.5 Z"
41
+ id="path2"
42
+ sodipodi:nodetypes="sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss" />
43
+ <path
44
+ d="m 19.362,17.904 a 6.5,6.5 0 1 0 -1.397,1.398 h -0.001 c 0.03,0.04 0.062,0.078 0.098,0.115 l 3.85,3.85 a 1.0002026,1.0002026 0 0 0 1.415,-1.414 l -3.85,-3.85 a 1.007,1.007 0 0 0 -0.115,-0.1 z M 19.62,14.06 a 5.5,5.5 0 1 1 -11,0 5.5,5.5 0 0 1 11,0 z"
45
+ id="path4" />
46
+ </svg>
@@ -0,0 +1,5 @@
1
+ // jest-dom adds custom jest matchers for asserting on DOM nodes.
2
+ // allows you to do things like:
3
+ // expect(element).toHaveTextContent(/react/i)
4
+ // learn more: https://github.com/testing-library/jest-dom
5
+ import "@testing-library/jest-dom";
@@ -0,0 +1,200 @@
1
+ // Matches placeholders like ${authorId} and captures "authorId".
2
+ const PLACEHOLDER_REGEX = /\$\{(.*?)\}/g;
3
+
4
+ // Matches optional blocks like [[ ... ]] and captures the inner content (incl. whitespace).
5
+ const OPTIONAL_BLOCK_REGEX = /\[\[([\s\S]*?)\]\]/g;
6
+
7
+ /* Helper Functions */
8
+
9
+ // Parse a CSV-like string into individual values.
10
+ // Example: "14104, 999;123" -> ["14104", "999", "123"]
11
+ const parseCsv = (raw) =>
12
+ String(raw)
13
+ .split(/[,\s;]+/g) // split on comma / whitespace / semicolon
14
+ .map((s) => s.trim())
15
+ .filter(Boolean); // drop empty values
16
+
17
+ // Extract placeholder keys from a string (can contain duplicates).
18
+ const placeholdersFromStringToArray = (str) => {
19
+ const keys = [];
20
+ for (const m of str.matchAll(PLACEHOLDER_REGEX)) {
21
+ keys.push(String(m[1]).trim());
22
+ }
23
+ return keys;
24
+ };
25
+
26
+ // Ensure a queue exists for a key; initialize from URL if not yet created.
27
+ const initQueue = (key, queues, params) => {
28
+ if (queues.has(key)) return;
29
+
30
+ // Use getAll() so BOTH forms work:
31
+ // - ?authorId=1,2,3
32
+ // - ?authorId=1&authorId=2
33
+ queues.set(key, params.getAll(key).flatMap(parseCsv));
34
+ };
35
+
36
+ // Consume the next value (FIFO) for a key, or undefined if none remain.
37
+ const nextValue = (key, queues, params) => {
38
+ initQueue(key, queues, params);
39
+ const queue = queues.get(key);
40
+ return queue?.length ? queue.shift() : undefined;
41
+ };
42
+
43
+ /**
44
+ * Replace placeholders in a string by consuming values from queues.
45
+ * Missing value => throw.
46
+ */
47
+ const replacePlaceholdersInString = (str, queues, params) =>
48
+ str.replace(PLACEHOLDER_REGEX, (_m, rawKey) => {
49
+ const key = String(rawKey).trim();
50
+ const value = nextValue(key, queues, params);
51
+
52
+ if (value === undefined) {
53
+ throw new Error(
54
+ `Missing value for placeholder \${${key}} in URL parameters`
55
+ );
56
+ }
57
+ return value;
58
+ });
59
+
60
+ /**
61
+ * Determine how many times an optional block can be repeated given currently available values,
62
+ * WITHOUT consuming values yet.
63
+ *
64
+ * Strategy:
65
+ * - Count how many times each placeholder key appears inside the block
66
+ * - For each key, compute floor(available / needed)
67
+ * - The block can repeat min(...) across all keys
68
+ */
69
+ const maxRepeatCountForOptionalBlock = (block, queues, params) => {
70
+ const keys = placeholdersFromStringToArray(block);
71
+ if (keys.length === 0) return 1;
72
+
73
+ // Count required occurrences per key inside the block.
74
+ const needed = new Map();
75
+ for (const k of keys) needed.set(k, (needed.get(k) || 0) + 1);
76
+
77
+ // Compute min( floor(available / needed) ) over keys.
78
+ let max = Infinity;
79
+ for (const [k, n] of needed.entries()) {
80
+ initQueue(k, queues, params);
81
+ const available = (queues.get(k) || []).length;
82
+ max = Math.min(max, Math.floor(available / n));
83
+ }
84
+ return Number.isFinite(max) ? max : 0;
85
+ };
86
+
87
+ /**
88
+ * Build a backend query string by replacing `${placeholders}` in a template
89
+ * with values from the URL query string (`window.location.search`).
90
+ *
91
+ * Supports:
92
+ * - CSV values in a single param: ?authorId=1,2,3
93
+ * - Repeated params: ?authorId=1&authorId=2
94
+ * - Optional block: [[ ... ]] (at most one)
95
+ *
96
+ * Rules (strict):
97
+ * - Placeholders outside the optional block are REQUIRED.
98
+ * - If an optional block exists, it is repeatable and emitted as many times
99
+ * as possible given available values.
100
+ * - Values are consumed FIFO per placeholder key (queue semantics).
101
+ * - After processing, having unused values for any USED key is an error (throw).
102
+ */
103
+ export const generateQueryString = (iframeSettings) => {
104
+ // Check: template is required in Standalone Mode.
105
+ if (!iframeSettings?.formatString) {
106
+ throw new Error("formatString is required");
107
+ }
108
+
109
+ // Normalize template and parse URL query string once.
110
+ const template = String(iframeSettings.formatString);
111
+ const params = new URLSearchParams(window.location.search);
112
+
113
+ /**
114
+ * Per-key FIFO queues holding values extracted from URL query params.
115
+ *
116
+ * Example URL:
117
+ * ?authorId=1,2&authorId=3
118
+ * results in the values:
119
+ * ["1","2","3"]
120
+ *
121
+ * queues.get("authorId") starts as ["1","2","3"] and shrinks as values are consumed
122
+ * by replacePlaceholdersInString() (via nextValue() and shift()).
123
+ */
124
+ const queues = new Map();
125
+
126
+ /**
127
+ * Main algorithm:
128
+ *
129
+ * We support at most ONE optional block: [[ ... ]].
130
+ * - If there is no optional block: replace all placeholders in the full template (strict).
131
+ * - If there is one optional block:
132
+ * 1) Replace placeholders in the required part BEFORE the block (strict, consumes values)
133
+ * 2) Repeat the optional block as many times as possible (consumes values per repetition)
134
+ * 3) Replace placeholders in the required part AFTER the block (strict, consumes values)
135
+ *
136
+ * Why "repeat" works (common pattern):
137
+ * - The first required occurrence (e.g. authors=${authorId}) consumes the first authorId value.
138
+ * - The optional block (e.g. [[ OR authors=${authorId} ]]) consumes any remaining authorId values,
139
+ * repeating until the queue for authorId runs out.
140
+ */
141
+ const matches = Array.from(template.matchAll(OPTIONAL_BLOCK_REGEX));
142
+ if (matches.length > 1) {
143
+ throw new Error("Only one optional block [[ ... ]] is supported");
144
+ }
145
+
146
+ let output = "";
147
+
148
+ // Case 1: No optional block => the whole template is strict.
149
+ if (matches.length === 0) {
150
+ output = replacePlaceholdersInString(template, queues, params);
151
+ } else {
152
+ // Case 2: Exactly one optional block => split template into requiredBefore + inner + requiredAfter.
153
+ const match = matches[0];
154
+ const full = match[0]; // full text including the markers: "[[ ... ]]"
155
+ const inner = match[1]; // inner text only (keeps whitespace), e.g. " OR authors=${authorId} "
156
+ const start = match.index ?? 0;
157
+ const end = start + full.length;
158
+
159
+ const requiredBefore = template.slice(0, start);
160
+ const requiredAfter = template.slice(end);
161
+
162
+ // 1) Required part BEFORE the optional block (strict).
163
+ // Any placeholder here MUST be satisfiable, otherwise we throw.
164
+ output = `${output}${replacePlaceholdersInString(
165
+ requiredBefore,
166
+ queues,
167
+ params
168
+ )}`;
169
+
170
+ // 2) Optional block (repeatable).
171
+ // We compute how many full repetitions are possible BEFORE consuming,
172
+ // then consume values once per repetition.
173
+ const repeatCount = maxRepeatCountForOptionalBlock(inner, queues, params);
174
+ for (let i = 0; i < repeatCount; i++) {
175
+ output = `${output}${replacePlaceholdersInString(inner, queues, params)}`;
176
+ }
177
+
178
+ // 3) Required part AFTER the optional block (strict).
179
+ // Any placeholder here MUST be satisfiable, otherwise we throw.
180
+ output = `${output}${replacePlaceholdersInString(
181
+ requiredAfter,
182
+ queues,
183
+ params
184
+ )}`;
185
+ }
186
+
187
+ /**
188
+ * Strict-mode validation:
189
+ * Any leftover values that were not consumed indicates a mismatch:
190
+ * the URL provided extra values that the template did not use.
191
+ */
192
+ for (const [key, rest] of queues.entries()) {
193
+ if (rest.length) {
194
+ throw new Error(`Unused values for "${key}": ${rest.join(", ")}`);
195
+ }
196
+ }
197
+
198
+ // Normalize whitespace so we never emit double spaces.
199
+ return output.replace(/\s+/g, " ").trim();
200
+ };