hearald 0.0.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.
- package/.eslintrc +14 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/TODO.md +34 -0
- package/package.json +45 -0
- package/public/analytics.html +20 -0
- package/public/css/analytics.css +381 -0
- package/public/js/.eslintrc +9 -0
- package/public/js/App.js +321 -0
- package/public/js/Tile.js +112 -0
- package/public/js/index.js +9 -0
- package/public/js/util.js +111 -0
- package/rollup.config.js +59 -0
- package/server/analytics.js +321 -0
- package/server/server.js +48 -0
- package/server/serverEvents.js +105 -0
- package/server/serverTrackError.js +47 -0
- package/shared/events.js +44 -0
- package/shared/utils.js +51 -0
package/public/js/App.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { html, Component } from "htm/preact";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getJson,
|
|
5
|
+
deepGet,
|
|
6
|
+
numFormatter,
|
|
7
|
+
getData,
|
|
8
|
+
fetchChartData,
|
|
9
|
+
fetchData,
|
|
10
|
+
} from "./util.js";
|
|
11
|
+
import Tile from "./Tile";
|
|
12
|
+
|
|
13
|
+
const DATE = new Date().toISOString().split("T")[0];
|
|
14
|
+
|
|
15
|
+
const Logo = () => html`
|
|
16
|
+
<svg
|
|
17
|
+
width="256"
|
|
18
|
+
height="256"
|
|
19
|
+
viewBox="0 0 256 256"
|
|
20
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
21
|
+
role="img"
|
|
22
|
+
aria-labelledby="title desc"
|
|
23
|
+
>
|
|
24
|
+
<title id="title">Hearald</title>
|
|
25
|
+
<desc id="desc">
|
|
26
|
+
Circular seal with a centered Gothic-style H monogram.
|
|
27
|
+
</desc>
|
|
28
|
+
|
|
29
|
+
<style>
|
|
30
|
+
.stroke {
|
|
31
|
+
stroke: white;
|
|
32
|
+
stroke-width: 8;
|
|
33
|
+
stroke-linecap: round;
|
|
34
|
+
stroke-linejoin: round;
|
|
35
|
+
fill: none;
|
|
36
|
+
}
|
|
37
|
+
.fill {
|
|
38
|
+
fill: #127d44;
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
41
|
+
|
|
42
|
+
<!-- Outer seal circle -->
|
|
43
|
+
<circle class="stroke" cx="128" cy="128" r="112" />
|
|
44
|
+
|
|
45
|
+
<!-- Inner decorative circle -->
|
|
46
|
+
<circle class="stroke" cx="128" cy="128" r="96" stroke-dasharray="14 10" />
|
|
47
|
+
|
|
48
|
+
<!-- Gothic-style H (stylized manually) -->
|
|
49
|
+
<g transform="translate(78,72)">
|
|
50
|
+
<!-- Left vertical stroke with spiked top/bottom -->
|
|
51
|
+
<path class="fill" d="M8 0 L20 0 L20 112 L8 112 L0 100 L0 12 Z" />
|
|
52
|
+
<!-- Right vertical stroke with spiked top/bottom -->
|
|
53
|
+
<path class="fill" d="M80 0 L92 0 L100 12 L100 100 L92 112 L80 112 Z" />
|
|
54
|
+
<!-- Crossbar with gothic curve -->
|
|
55
|
+
<path class="fill" d="M20 48 H80 V64 H20 Z" />
|
|
56
|
+
<!-- Decorative inward serifs -->
|
|
57
|
+
<path class="fill" d="M20 0 q10 8 0 16 Z" />
|
|
58
|
+
<path class="fill" d="M80 0 q-10 8 0 16 Z" />
|
|
59
|
+
<path class="fill" d="M20 96 q10 8 0 16 Z" />
|
|
60
|
+
<path class="fill" d="M80 96 q-10 8 0 16 Z" />
|
|
61
|
+
</g>
|
|
62
|
+
</svg>
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const getDataFn = ({ n, d, q }) =>
|
|
66
|
+
getData({
|
|
67
|
+
n,
|
|
68
|
+
d,
|
|
69
|
+
q,
|
|
70
|
+
endpointBuilder: (n, d, q) => {
|
|
71
|
+
return `${window.location.pathname.replace(/\/$/, "")}/data/${n}/${d}${
|
|
72
|
+
q ? "?" + q : ""
|
|
73
|
+
}`;
|
|
74
|
+
},
|
|
75
|
+
fetchStats: getJson,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let UI = [];
|
|
79
|
+
|
|
80
|
+
async function fetchUI() {
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(window.location.href + "/layout");
|
|
83
|
+
UI = await res.json();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
UI = [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class App extends Component {
|
|
90
|
+
constructor(props) {
|
|
91
|
+
super(props);
|
|
92
|
+
this.state = {
|
|
93
|
+
numDays: 30,
|
|
94
|
+
date: DATE,
|
|
95
|
+
chartField: "users",
|
|
96
|
+
chartContext: "all",
|
|
97
|
+
uiSectionsLoaded: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async componentDidMount() {
|
|
102
|
+
await fetchUI();
|
|
103
|
+
this.setState({ uiSectionsLoaded: true });
|
|
104
|
+
fetchData({
|
|
105
|
+
numDays: this.state.numDays,
|
|
106
|
+
date: this.state.date,
|
|
107
|
+
getDataFn,
|
|
108
|
+
onLoadComplete: (stats) => this.setState({ stats }),
|
|
109
|
+
});
|
|
110
|
+
fetchChartData({
|
|
111
|
+
numDays: this.state.numDays,
|
|
112
|
+
date: this.state.date,
|
|
113
|
+
chartContext: this.state.chartContext,
|
|
114
|
+
chartField: this.state.chartField,
|
|
115
|
+
getDataFn,
|
|
116
|
+
onLoadProgress: (i, n) => {
|
|
117
|
+
this.setState({ chartData: { loading: { max: n, value: i } } });
|
|
118
|
+
},
|
|
119
|
+
onLoadComplete: (data, max) => {
|
|
120
|
+
this.setState({ chartData: { loading: false, data, max } });
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async fetchChartData({ numDays, date, chartContext, chartField }) {
|
|
126
|
+
await fetchChartData({
|
|
127
|
+
numDays: numDays || this.state.numDays,
|
|
128
|
+
date: date || this.state.date,
|
|
129
|
+
chartContext: chartContext || this.state.chartContext,
|
|
130
|
+
chartField: chartField || this.state.chartField,
|
|
131
|
+
getDataFn,
|
|
132
|
+
onLoadProgress: (i, n) =>
|
|
133
|
+
this.setState({ chartData: { loading: { max: n, value: i } } }),
|
|
134
|
+
onLoadComplete: (data, max) =>
|
|
135
|
+
this.setState({ chartData: { loading: false, data, max } }),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async fetchData({ numDays, date }) {
|
|
140
|
+
await fetchData({
|
|
141
|
+
numDays: numDays || this.state.numDays,
|
|
142
|
+
date: date || this.state.date,
|
|
143
|
+
getDataFn,
|
|
144
|
+
onLoadComplete: (stats) => this.setState({ stats }),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
changeNumDays(e) {
|
|
149
|
+
const numDays = e.target.value;
|
|
150
|
+
this.setState({ numDays });
|
|
151
|
+
this.fetchData({ numDays });
|
|
152
|
+
this.fetchChartData({ numDays });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
changeDate(e) {
|
|
156
|
+
const date = e.target.value;
|
|
157
|
+
this.setState({ date });
|
|
158
|
+
this.fetchData({ date });
|
|
159
|
+
this.fetchChartData({ date });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
changeChartField(e) {
|
|
163
|
+
const chartField = e.target.value;
|
|
164
|
+
this.setState({ chartField });
|
|
165
|
+
this.fetchChartData({ chartField });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
changeChartContext(e) {
|
|
169
|
+
const chartContext = e.target.value;
|
|
170
|
+
this.setState({ chartContext });
|
|
171
|
+
this.fetchChartData({ chartContext });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
render(
|
|
175
|
+
_props,
|
|
176
|
+
{
|
|
177
|
+
chartField,
|
|
178
|
+
chartContext,
|
|
179
|
+
numDays,
|
|
180
|
+
date,
|
|
181
|
+
stats = null,
|
|
182
|
+
chartData = null,
|
|
183
|
+
uiSectionsLoaded,
|
|
184
|
+
}
|
|
185
|
+
) {
|
|
186
|
+
return html`
|
|
187
|
+
<div id="analytics">
|
|
188
|
+
<div class="analytics__header">
|
|
189
|
+
<div class="header__logo">
|
|
190
|
+
<${Logo} />
|
|
191
|
+
</div>
|
|
192
|
+
<span>Hearald</span>
|
|
193
|
+
<input
|
|
194
|
+
type="text"
|
|
195
|
+
class="analytics__header__input"
|
|
196
|
+
onchange=${(e) => this.changeNumDays(e)}
|
|
197
|
+
value=${numDays}
|
|
198
|
+
/>
|
|
199
|
+
<input
|
|
200
|
+
type="text"
|
|
201
|
+
class="analytics__header__input"
|
|
202
|
+
onchange=${(e) => this.changeDate(e)}
|
|
203
|
+
value=${date}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
${!uiSectionsLoaded || !stats || !stats.metrics
|
|
207
|
+
? html` <div><section class="analytics">Loading...</section></div> `
|
|
208
|
+
: html`
|
|
209
|
+
<div>
|
|
210
|
+
<section class="analytics analytics--chart">
|
|
211
|
+
<form
|
|
212
|
+
class="analytics__menu"
|
|
213
|
+
onsubmit=${(e) => e.preventDefault()}
|
|
214
|
+
>
|
|
215
|
+
<label>
|
|
216
|
+
Context
|
|
217
|
+
<select
|
|
218
|
+
name="context"
|
|
219
|
+
onchange=${(e) => this.changeChartContext(e)}
|
|
220
|
+
>
|
|
221
|
+
${UI.chartContexts.map(
|
|
222
|
+
({ id, menuName }) =>
|
|
223
|
+
html`
|
|
224
|
+
<option
|
|
225
|
+
value="${id}"
|
|
226
|
+
selected=${chartContext === id}
|
|
227
|
+
>
|
|
228
|
+
${menuName}
|
|
229
|
+
</option>
|
|
230
|
+
`
|
|
231
|
+
)}
|
|
232
|
+
</select>
|
|
233
|
+
</label>
|
|
234
|
+
<label>
|
|
235
|
+
Field
|
|
236
|
+
<select
|
|
237
|
+
name="field"
|
|
238
|
+
onchange=${(e) => this.changeChartField(e)}
|
|
239
|
+
>
|
|
240
|
+
${stats?.metrics?.all
|
|
241
|
+
? Object.keys(stats.metrics.all).map(
|
|
242
|
+
(f) =>
|
|
243
|
+
html`
|
|
244
|
+
<option
|
|
245
|
+
value="${f}"
|
|
246
|
+
selected=${chartField === f}
|
|
247
|
+
>
|
|
248
|
+
${f}
|
|
249
|
+
</option>
|
|
250
|
+
`
|
|
251
|
+
)
|
|
252
|
+
: ""}
|
|
253
|
+
</select>
|
|
254
|
+
</label>
|
|
255
|
+
</form>
|
|
256
|
+
<div class="bar-chart">
|
|
257
|
+
${!chartData &&
|
|
258
|
+
html` <span class="bar-chart__empty">no data</span> `}
|
|
259
|
+
${chartData &&
|
|
260
|
+
chartData.loading &&
|
|
261
|
+
html`
|
|
262
|
+
<progress
|
|
263
|
+
class="bar-chart__empty"
|
|
264
|
+
max=${chartData.loading.max}
|
|
265
|
+
value=${chartData.loading.value}
|
|
266
|
+
></progress>
|
|
267
|
+
`}
|
|
268
|
+
${chartData &&
|
|
269
|
+
!chartData.loading &&
|
|
270
|
+
html`
|
|
271
|
+
<div class="bar-chart__scale">${chartData.max}</div>
|
|
272
|
+
${chartData.data.map(
|
|
273
|
+
(v) =>
|
|
274
|
+
html`
|
|
275
|
+
<div
|
|
276
|
+
class="bar-chart__bar"
|
|
277
|
+
style="--bar-height:${v.value / chartData.max}"
|
|
278
|
+
>
|
|
279
|
+
<div class="bar-chart__bar__label">
|
|
280
|
+
${v.date}
|
|
281
|
+
<br />
|
|
282
|
+
<b>${numFormatter(v.value)}</b>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
`
|
|
286
|
+
)}
|
|
287
|
+
`}
|
|
288
|
+
</div>
|
|
289
|
+
</section>
|
|
290
|
+
${UI.sections.map((statsSection) => {
|
|
291
|
+
return html`
|
|
292
|
+
<section class="analytics analytics--${statsSection.id}">
|
|
293
|
+
${statsSection.menuName !== "" &&
|
|
294
|
+
html`
|
|
295
|
+
<h2 class="analytics__title">
|
|
296
|
+
${statsSection.menuName}
|
|
297
|
+
</h2>
|
|
298
|
+
`}
|
|
299
|
+
${statsSection.stats.map((f) => {
|
|
300
|
+
return html`
|
|
301
|
+
<${Tile}
|
|
302
|
+
title="${f.label}"
|
|
303
|
+
type="${f.type}"
|
|
304
|
+
value=${deepGet(stats.metrics, f.key)}
|
|
305
|
+
span=${f.span || null}
|
|
306
|
+
${f.type === "percent" ? "percent" : ""}
|
|
307
|
+
${f.type === "histogram" ? "histogram" : ""}
|
|
308
|
+
/>
|
|
309
|
+
`;
|
|
310
|
+
})}
|
|
311
|
+
</section>
|
|
312
|
+
`;
|
|
313
|
+
})}
|
|
314
|
+
</div>
|
|
315
|
+
`}
|
|
316
|
+
</div>
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export default App;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { html, Component } from "htm/preact";
|
|
2
|
+
|
|
3
|
+
const prettyNumericFormatter = (val) => {
|
|
4
|
+
const digits = Math.log10(val);
|
|
5
|
+
let truncDigits = 0;
|
|
6
|
+
let decimalPlaces = 0;
|
|
7
|
+
let suffix = "";
|
|
8
|
+
if (digits > 8) {
|
|
9
|
+
truncDigits = 9;
|
|
10
|
+
suffix = "b";
|
|
11
|
+
decimalPlaces = 2;
|
|
12
|
+
} else if (digits > 5) {
|
|
13
|
+
truncDigits = 6;
|
|
14
|
+
suffix = "m";
|
|
15
|
+
decimalPlaces = 2;
|
|
16
|
+
} else if (digits > 3) {
|
|
17
|
+
truncDigits = 3;
|
|
18
|
+
suffix = "k";
|
|
19
|
+
decimalPlaces = 2;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const numeric = Math.floor(val / 10 ** truncDigits);
|
|
23
|
+
if (decimalPlaces) {
|
|
24
|
+
const fractional =
|
|
25
|
+
(val % 10 ** decimalPlaces) % 10 ** (truncDigits - decimalPlaces);
|
|
26
|
+
return `${numeric}.${fractional}${suffix}`;
|
|
27
|
+
} else {
|
|
28
|
+
return `${numeric}${suffix}`;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const percentFormatter = (val) =>
|
|
33
|
+
`${(Math.round(val * 100) / 100).toFixed(2)}%`;
|
|
34
|
+
|
|
35
|
+
const plainFormatter = (val) => val;
|
|
36
|
+
|
|
37
|
+
const getFormattedValue = (value, percent = false, plain = false) => {
|
|
38
|
+
let formattedValue;
|
|
39
|
+
let formatter = prettyNumericFormatter;
|
|
40
|
+
if (percent) {
|
|
41
|
+
if (plain) {
|
|
42
|
+
console.error("Multiple formatters specified, using percent formatter");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
formatter = percentFormatter;
|
|
46
|
+
} else if (plain) {
|
|
47
|
+
formatter = plainFormatter;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isNaN(value)) {
|
|
51
|
+
formattedValue = "(no data)";
|
|
52
|
+
} else {
|
|
53
|
+
formattedValue = formatter(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return formattedValue;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
class Tile extends Component {
|
|
60
|
+
render({
|
|
61
|
+
title,
|
|
62
|
+
value,
|
|
63
|
+
type,
|
|
64
|
+
percent = false,
|
|
65
|
+
plain = false,
|
|
66
|
+
endRow = false,
|
|
67
|
+
span = null,
|
|
68
|
+
}) {
|
|
69
|
+
return html`
|
|
70
|
+
<div
|
|
71
|
+
class="analytics__tile ${span ? `analytics__tile--span-${span}` : ""}"
|
|
72
|
+
>
|
|
73
|
+
<div class="analytics__tile__header">${title}</div>
|
|
74
|
+
${type === "histogram"
|
|
75
|
+
? html`
|
|
76
|
+
<!-- div class="analytics__tile__value" -->
|
|
77
|
+
<ul class="analytics__tile__histogram">
|
|
78
|
+
${value && value.length
|
|
79
|
+
? value.map(
|
|
80
|
+
([label, count]) => html`
|
|
81
|
+
<li class="analytics__tile__histogram__row">
|
|
82
|
+
<div class="analytics__tile__histogram__value">
|
|
83
|
+
${count}
|
|
84
|
+
</div>
|
|
85
|
+
<div
|
|
86
|
+
class="analytics__tile__histogram__label"
|
|
87
|
+
style="--bar-width:${count / (value[0]?.[1] || 1)}"
|
|
88
|
+
>
|
|
89
|
+
${label}
|
|
90
|
+
</div>
|
|
91
|
+
</li>
|
|
92
|
+
`
|
|
93
|
+
)
|
|
94
|
+
: html`
|
|
95
|
+
<li class="analytics__tile__histogram__row">No data</li>
|
|
96
|
+
`}
|
|
97
|
+
</ul>
|
|
98
|
+
<!-- /div -->
|
|
99
|
+
`
|
|
100
|
+
: html`
|
|
101
|
+
<div class="analytics__tile__value" data-value=${value}>
|
|
102
|
+
${getFormattedValue(value, percent, plain)}
|
|
103
|
+
</div>
|
|
104
|
+
`}
|
|
105
|
+
</div>
|
|
106
|
+
${endRow &&
|
|
107
|
+
html` <div class="analytics__tile analytics__tile--end-row" /> `}
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default Tile;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { iso } from "../../shared/utils.js";
|
|
2
|
+
|
|
3
|
+
/* START methods for fetching data from the server */
|
|
4
|
+
|
|
5
|
+
export const dataCache = {};
|
|
6
|
+
|
|
7
|
+
// Fetches data from the server with caching
|
|
8
|
+
export async function getData({ n, d, q, endpointBuilder, fetchStats }) {
|
|
9
|
+
const key = [n, d, q].join("_");
|
|
10
|
+
if (dataCache[key]) {
|
|
11
|
+
return dataCache[key];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const endpoint = endpointBuilder
|
|
15
|
+
? endpointBuilder(n, d, q)
|
|
16
|
+
: `/data/${n}/${d}${q ? "?" + q : ""}`;
|
|
17
|
+
console.log("endpoint", endpoint);
|
|
18
|
+
|
|
19
|
+
const data = await fetchStats(endpoint);
|
|
20
|
+
dataCache[key] = data;
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fetches and builds chart data for a given field/context over a time window
|
|
25
|
+
export async function fetchChartData({
|
|
26
|
+
numDays,
|
|
27
|
+
date,
|
|
28
|
+
chartContext,
|
|
29
|
+
chartField,
|
|
30
|
+
getDataFn,
|
|
31
|
+
|
|
32
|
+
onLoadProgress, // (i, n) => void
|
|
33
|
+
onLoadComplete, // (data, max) => void
|
|
34
|
+
}) {
|
|
35
|
+
const n = numDays;
|
|
36
|
+
const d = date;
|
|
37
|
+
const field = chartField;
|
|
38
|
+
const context = chartContext;
|
|
39
|
+
|
|
40
|
+
const [yyyy, mm, dd] = d.split("-");
|
|
41
|
+
const startDate = new Date(yyyy, mm - 1, dd);
|
|
42
|
+
|
|
43
|
+
let data = [];
|
|
44
|
+
let max = 1;
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < n; i++) {
|
|
47
|
+
let day = new Date(startDate);
|
|
48
|
+
day.setDate(startDate.getDate() - i);
|
|
49
|
+
onLoadProgress(i, n);
|
|
50
|
+
|
|
51
|
+
const json = await getDataFn({ n: 1, d: iso(day) });
|
|
52
|
+
const v = json.metrics[context][field] || 0;
|
|
53
|
+
|
|
54
|
+
data.unshift({ date: iso(day), value: v });
|
|
55
|
+
max = Math.max(max, v);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onLoadComplete(data, max);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fetches and sets the main stats data for a given date/range
|
|
62
|
+
export async function fetchData({
|
|
63
|
+
numDays,
|
|
64
|
+
date,
|
|
65
|
+
getDataFn,
|
|
66
|
+
onLoadComplete, // (stats) => void
|
|
67
|
+
}) {
|
|
68
|
+
const n = numDays;
|
|
69
|
+
const d = date;
|
|
70
|
+
const stats = await getDataFn({ n, d });
|
|
71
|
+
onLoadComplete(stats);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* END methods for fetching data from the server */
|
|
75
|
+
|
|
76
|
+
/* START helper methods */
|
|
77
|
+
|
|
78
|
+
export const getJson = async (url) => {
|
|
79
|
+
const response = await fetch(url, {
|
|
80
|
+
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
81
|
+
});
|
|
82
|
+
return response.json();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const postJson = async (url, body) => {
|
|
86
|
+
const response = await fetch(url, {
|
|
87
|
+
method: "post",
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
90
|
+
});
|
|
91
|
+
return response.json();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const deepGet = (obj, path) => {
|
|
95
|
+
const parts = path.split(".");
|
|
96
|
+
let current = obj;
|
|
97
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
98
|
+
if (current == null) return undefined;
|
|
99
|
+
current = current[parts[i]];
|
|
100
|
+
}
|
|
101
|
+
return current;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function numFormatter(n) {
|
|
105
|
+
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
|
|
106
|
+
return new Intl.NumberFormat().format(n);
|
|
107
|
+
}
|
|
108
|
+
return n;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* END helper methods */
|
package/rollup.config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import resolve from "@rollup/plugin-node-resolve";
|
|
4
|
+
import postcss from "rollup-plugin-postcss";
|
|
5
|
+
|
|
6
|
+
// Custom plugin to inline JS and CSS into HTML
|
|
7
|
+
function inlineAssetsPlugin({ htmlFile, outputFile }) {
|
|
8
|
+
return {
|
|
9
|
+
name: "inline-assets",
|
|
10
|
+
async writeBundle(options, bundle) {
|
|
11
|
+
const htmlPath = path.resolve(htmlFile);
|
|
12
|
+
let html = fs.readFileSync(htmlPath, "utf8");
|
|
13
|
+
|
|
14
|
+
// Find JS and CSS output from bundle
|
|
15
|
+
const jsBundle = Object.values(bundle).find(
|
|
16
|
+
(file) => file.type === "chunk" && file.isEntry
|
|
17
|
+
);
|
|
18
|
+
const cssBundle = Object.values(bundle).find(
|
|
19
|
+
(file) => file.type === "asset" && file.fileName.endsWith(".css")
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const js = jsBundle ? jsBundle.code : "";
|
|
23
|
+
const css = cssBundle ? cssBundle.source : "";
|
|
24
|
+
|
|
25
|
+
// Replace <script src=...> and <link rel="stylesheet" ...> with inline tags
|
|
26
|
+
html = html.replace(
|
|
27
|
+
/<script\s+src="[^"]*"[^>]*><\/script>/,
|
|
28
|
+
`<script>${js}</script>`
|
|
29
|
+
);
|
|
30
|
+
// Replace any <link rel="stylesheet" ...> (with any attributes)
|
|
31
|
+
html = html.replace(
|
|
32
|
+
/<link\s+rel=["']stylesheet["'][^>]*>/gi,
|
|
33
|
+
`<style>${css}</style>`
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Write the inlined HTML
|
|
37
|
+
fs.writeFileSync(path.resolve(options.dir, outputFile), html);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
input: "public/js/index.js", // Use JS entry point
|
|
44
|
+
output: {
|
|
45
|
+
dir: "dist",
|
|
46
|
+
format: "iife",
|
|
47
|
+
},
|
|
48
|
+
plugins: [
|
|
49
|
+
resolve(),
|
|
50
|
+
postcss({
|
|
51
|
+
inject: false,
|
|
52
|
+
extract: true,
|
|
53
|
+
}),
|
|
54
|
+
inlineAssetsPlugin({
|
|
55
|
+
htmlFile: "public/analytics.html",
|
|
56
|
+
outputFile: "analytics.html",
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
};
|