Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
fc74214
feat: add Plausible analytics provider
thebuilder Jul 20, 2026
9981c24
refactor: centralize analytics provider contracts
thebuilder Jul 20, 2026
7fa04c4
fix: enforce analytics provider capabilities
thebuilder Jul 20, 2026
d440f0e
fix: preserve Plausible report semantics
thebuilder Jul 20, 2026
995d406
fix: close multi-provider quality review findings
thebuilder Jul 20, 2026
9412fcd
feat: reshape analytics provider settings
thebuilder Jul 20, 2026
b9fcac8
fix: harden analytics settings interactions
thebuilder Jul 20, 2026
6b2350f
fix: order analytics breakdowns by selected metric
thebuilder Jul 20, 2026
e044f5f
fix: mark ongoing analytics periods
thebuilder Jul 20, 2026
6782ff8
feat: add provider-neutral event drilldown
thebuilder Jul 20, 2026
7ae9955
fix: align analytics settings with Umbraco tabs
thebuilder Jul 20, 2026
ab3b894
fix: use local provider brand assets
thebuilder Jul 20, 2026
de01ed4
refactor: make analytics event capabilities explicit
thebuilder Jul 21, 2026
4e4ee9c
refactor: centralize provider metadata in adapters
thebuilder Jul 21, 2026
17f05a8
fix: publish nullable mock scenarios in OpenAPI
thebuilder Jul 21, 2026
adc6fd8
fix: refresh only breakdowns on metric changes
thebuilder Jul 21, 2026
b779fc0
refactor: remove Vercel-default test constructors
thebuilder Jul 21, 2026
fdf4178
refactor: rename shared analytics UI identity
thebuilder Jul 21, 2026
2f56ad5
refactor: isolate provider definitions from clients
thebuilder Jul 21, 2026
a72ee40
docs: make provider guidance extensible
thebuilder Jul 21, 2026
b012dcd
docs: clarify provider requirements and credentials
thebuilder Jul 21, 2026
3461a22
docs: link Plausible Stats API setup
thebuilder Jul 21, 2026
5eca20a
docs: simplify Vercel credential setup
thebuilder Jul 21, 2026
ca8bda7
fix: restore Plausible referrer favicons
thebuilder Jul 21, 2026
42355f2
fix: derive Plausible site base URL
thebuilder Jul 21, 2026
ff2d203
feat: configure Plausible event properties
thebuilder Jul 21, 2026
f1a2fa8
feat: discover Plausible event properties
thebuilder Jul 21, 2026
eafa7d2
fix: polish analytics settings actions
thebuilder Jul 21, 2026
52351a6
fix: simplify event details dialog
thebuilder Jul 21, 2026
70a1c21
fix: refine floating settings actions
thebuilder Jul 21, 2026
eb37a71
fix: stabilize event details layout
thebuilder Jul 21, 2026
e5085de
fix: improve empty event details state
thebuilder Jul 21, 2026
8a0adeb
fix: enforce analytics provider capabilities
thebuilder Jul 21, 2026
405305a
docs: simplify client generation guidance
thebuilder Jul 21, 2026
4c85a7c
fix: guard event property exploration by capability
thebuilder Jul 21, 2026
29cc276
test: characterize asymmetric analytics providers
thebuilder Jul 21, 2026
96b0f5a
refactor: centralize provider connection metadata
thebuilder Jul 21, 2026
956966a
refactor: register analytics providers from definitions
thebuilder Jul 21, 2026
b44a456
refactor: make provider catalog the registration source
thebuilder Jul 21, 2026
7ff27fe
feat: expose safe analytics provider descriptors
thebuilder Jul 21, 2026
cc9bf18
refactor: render settings from provider descriptors
thebuilder Jul 21, 2026
c58ac11
fix: normalize generated client whitespace
thebuilder Jul 21, 2026
25ba39c
docs: align product intent with analytics providers
thebuilder Jul 21, 2026
ca56c61
refactor: separate provider registration from metadata
thebuilder Jul 21, 2026
1e92448
refactor: derive capabilities from provider clients
thebuilder Jul 21, 2026
46c7f74
refactor: delegate fallback urls to providers
thebuilder Jul 21, 2026
509acbf
fix: make event property empty state provider neutral
thebuilder Jul 21, 2026
9dfc39f
refactor: centralize provider capability metadata
thebuilder Jul 21, 2026
975b4f9
docs: refresh marketplace settings screenshot
thebuilder Jul 21, 2026
a33d11d
fix: align floating settings actions with content
thebuilder Jul 21, 2026
c9b14a1
fix: simplify event property guidance
thebuilder Jul 21, 2026
5779617
fix: distinguish floating settings actions
thebuilder Jul 21, 2026
64dc65e
refactor: remove redundant provider overview
thebuilder Jul 21, 2026
25a8e45
docs: refresh marketplace settings screenshot
thebuilder Jul 21, 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
6 changes: 3 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: Web Analytics
description: A native editorial instrument for clear Vercel traffic insight inside Umbraco.
description: A native editorial instrument for clear web analytics insight inside Umbraco.
colors:
backoffice-ink: "#060606"
backoffice-ink-muted: "#2e2b29"
Expand Down Expand Up @@ -88,9 +88,9 @@ components:

**Creative North Star: "The Editorial Instrument"**

Web Analytics is a precise but approachable instrument inside the editorial workspace. It should reveal the traffic signal an editor needs, then get out of the way. The visual language is friendly and conversational, but its structure is disciplined: familiar Umbraco controls, clear hierarchy, compact reporting, and feedback close to the action that caused it.
Web Analytics is a precise but approachable instrument inside the editorial workspace. It should reveal the site-wide or page-level traffic signal an editor needs, then get out of the way. The visual language is friendly and conversational, but its structure is disciplined: familiar Umbraco controls, clear hierarchy, compact reporting, capability-aware depth, and feedback close to the action that caused it.

The extension belongs to the backoffice rather than merely living inside it. It inherits Umbraco UI Library semantics, Lato typography, spacing, focus treatment, and surface hierarchy. Custom analytics patterns may borrow the clarity of Vercel Analytics, but they must be translated into Umbraco's interaction language.
The extension belongs to the backoffice rather than merely living inside it. It inherits Umbraco UI Library semantics, Lato typography, spacing, focus treatment, and surface hierarchy. Custom analytics patterns may borrow the clarity of Vercel Analytics, but Umbraco owns the interaction language: provider capabilities change the available depth, not the identity or basic behavior of the interface.

The system explicitly rejects the density, configuration burden, and specialist terminology of an overly dense enterprise analytics platform. It is not Google Analytics, and it must never feel like a separate third-party dashboard embedded in Umbraco.

Expand Down
8 changes: 5 additions & 3 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ The primary users are content editors and marketing staff working in the authent

## Product Purpose

Web Analytics brings Vercel Web Analytics into the Umbraco backoffice. It helps editors understand site and page traffic where they already manage content, while administrators retain secure control of project connections and server-side credentials.
Web Analytics brings insights from Vercel Web Analytics and Plausible into the Umbraco editorial workflow. It gives editors clear site-wide and page-level traffic context where they already manage content, while administrators retain secure control of connections and server-side credentials.

Success means that an editor can open Analytics, understand the important traffic signals quickly, and return to their content work without needing to visit Vercel or a separate analytics suite.
Vercel and Plausible are bundled integrations. Reporting depth adapts to the capabilities and data available from the selected provider, while the editorial experience remains consistent and focused.

Success means that an editor can open Analytics, understand the important traffic signals quickly, and return to their content work without needing to visit a separate analytics suite.

## Positioning

Vercel traffic insights where editors already work, without exposing credentials or requiring another analytics dashboard.
Clear site-wide and page-level insight where editors already work, without exposing credentials or requiring another analytics dashboard.

## Brand Personality

Expand Down
115 changes: 64 additions & 51 deletions README.md

Large diffs are not rendered by default.

Binary file modified docs/screenshots/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Text.Json;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Api.Management.OpenApi;
#endif
Expand All @@ -18,7 +19,7 @@

namespace TheBuilder.WebAnalytics.Example;

public sealed class VercelAnalyticsOpenApiComposer : IComposer
public sealed class WebAnalyticsOpenApiComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
Expand All @@ -42,7 +43,7 @@ public void Compose(IUmbracoBuilder builder)
return Task.CompletedTask;
})));
#else
builder.Services.AddSingleton<IOperationIdHandler, VercelAnalyticsOperationIdHandler>();
builder.Services.AddSingleton<IOperationIdHandler, WebAnalyticsOperationIdHandler>();
builder.Services.Configure<SwaggerGenOptions>(options =>
{
options.SwaggerDoc(
Expand All @@ -52,18 +53,41 @@ public void Compose(IUmbracoBuilder builder)
Title = "Web Analytics Backoffice API",
Version = "1.0",
});
options.OperationFilter<VercelAnalyticsOperationSecurityFilter>();
options.OperationFilter<WebAnalyticsOperationSecurityFilter>();
options.SchemaFilter<NullableEnumSchemaFilter>();
});
#endif
}

#if !UMBRACO_18_OR_LATER
private sealed class VercelAnalyticsOperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase
private sealed class WebAnalyticsOperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase
{
protected override string ApiName => Constants.ApiName;
}

private sealed class VercelAnalyticsOperationIdHandler(IOptions<ApiVersioningOptions> apiVersioningOptions)
private sealed class NullableEnumSchemaFilter : ISchemaFilter
{
public void Apply(IOpenApiSchema schema, SchemaFilterContext context)
{
foreach (var property in context.Type.GetProperties())
{
if (Nullable.GetUnderlyingType(property.PropertyType)?.IsEnum is not true)
continue;

var propertyName = JsonNamingPolicy.CamelCase.ConvertName(property.Name);
schema.Required?.Remove(propertyName);
if (schema.Properties?.TryGetValue(propertyName, out var propertySchema) is true)
{
schema.Properties[propertyName] = new OpenApiSchema
{
AnyOf = [propertySchema, new OpenApiSchema { Type = JsonSchemaType.Null }]
};
}
}
}
}

private sealed class WebAnalyticsOperationIdHandler(IOptions<ApiVersioningOptions> apiVersioningOptions)
: OperationIdHandler(apiVersioningOptions)
{
protected override bool CanHandle(
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 18 additions & 2 deletions src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fetch from 'node-fetch';
import chalk from 'chalk';
import { createClient, defaultPlugins } from '@hey-api/openapi-ts';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';

const getSwaggerUrl = (argumentsToParse) => argumentsToParse.find((argument) => argument !== '--');
Expand All @@ -24,10 +25,23 @@ const printGenerationError = (error, message) => {
error('Review the generator error and the client configuration in generate-openapi.js.');
};

export async function normalizeGeneratedClient(output = 'src/api') {
const files = await readdir(output);
await Promise.all(files
.filter((file) => file.endsWith('.ts'))
.map(async (file) => {
const path = `${output}/${file}`;
const source = await readFile(path, 'utf8');
const normalized = source.replace(/[\t ]+(?=\r?$)/gm, '');
if (normalized !== source) await writeFile(path, normalized);
}));
}

export async function generateOpenApiClient({
swaggerUrl = getSwaggerUrl(process.argv.slice(2)),
fetchImplementation = fetch,
createClientImplementation = createClient,
normalizeGeneratedOutput = normalizeGeneratedClient,
log = console.log,
error = console.error,
} = {}) {
Expand Down Expand Up @@ -61,19 +75,21 @@ export async function generateOpenApiClient({

log(`Calling ${chalk.yellow('hey-api')} to generate TypeScript client`);
try {
const openApiDocument = await response.json();
await createClientImplementation({
input: swaggerUrl,
input: openApiDocument,
output: 'src/api',
plugins: [
...defaultPlugins,
'@hey-api/client-fetch',
{
name: '@hey-api/sdk',
asClass: true,
classNameBuilder: '{{name}}Service',
classNameBuilder: 'WebAnalyticsService',
},
],
});
await normalizeGeneratedOutput();
log('OpenAPI client generated successfully');
return true;
} catch (exception) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { spawn } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { generateOpenApiClient } from './generate-openapi.js';
import { generateOpenApiClient, normalizeGeneratedClient } from './generate-openapi.js';

const scriptPath = fileURLToPath(new URL('./generate-openapi.js', import.meta.url));

Expand Down Expand Up @@ -86,7 +89,7 @@ describe('generate-openapi', () => {
const errors = [];
const succeeded = await generateOpenApiClient({
swaggerUrl: 'http://localhost/openapi.json',
fetchImplementation: async () => ({ ok: true }),
fetchImplementation: async () => ({ ok: true, json: async () => ({ openapi: '3.0.0', paths: {} }) }),
createClientImplementation: async () => {
throw new Error('generation failed');
},
Expand All @@ -104,12 +107,16 @@ describe('generate-openapi', () => {
it('does not report success until the client generator resolves', async () => {
const logs = [];
let resolveGeneration;
let normalized = false;
const generation = generateOpenApiClient({
swaggerUrl: 'http://localhost/openapi.json',
fetchImplementation: async () => ({ ok: true }),
fetchImplementation: async () => ({ ok: true, json: async () => ({ openapi: '3.0.0', paths: {} }) }),
createClientImplementation: () => new Promise((resolve) => {
resolveGeneration = resolve;
}),
normalizeGeneratedOutput: async () => {
normalized = true;
},
log: (message) => logs.push(message),
error: () => {},
});
Expand All @@ -121,6 +128,22 @@ describe('generate-openapi', () => {
resolveGeneration();

await expect(generation).resolves.toBe(true);
expect(normalized).toBe(true);
expect(logs).toContain('OpenAPI client generated successfully');
});

it('normalizes trailing whitespace from generated TypeScript files', async () => {
const output = await mkdtemp(join(tmpdir(), 'web-analytics-openapi-'));
const generatedFile = join(output, 'sdk.gen.ts');
try {
await writeFile(generatedFile, 'export const generated = true; \r\n \r\n');

await normalizeGeneratedClient(output);

await expect(readFile(generatedFile, 'utf8')).resolves.toBe('export const generated = true;\r\n\r\n');
} finally {
await rm(output, { recursive: true, force: true });
}
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import "./event-table.element.js";
import "./flag-card.element.js";
import { stateData, type AsyncState } from "./async-state.js";

@customElement("vercel-analytics-breakdown-grid")
export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement) {
@customElement("web-analytics-breakdown-grid")
export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement) {
@property({ attribute: false }) cards: ReadonlyArray<DashboardCard> = [];
@property({ attribute: false }) breakdowns: Partial<Record<AnalyticsDimension, AsyncState<AnalyticsBreakdown>>> = {};
@property({ attribute: false }) events: AsyncState<AnalyticsEventsReport> = { status: "loading" };
Expand All @@ -24,6 +24,10 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
@property() acquisitionView: AcquisitionView = "referrers";
@property() utmDimension: UtmDimension = "UtmSource";
@property() baseUrl?: string;
@property({ type: Boolean }) supportsEvents = true;
@property({ type: Boolean }) supportsGlobalEventFiltering = false;
@property({ type: Boolean }) supportsEventDetails = true;
@property({ type: Boolean }) supportsFlags = true;

#dispatch(name: string, detail?: unknown): void {
this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
Expand Down Expand Up @@ -112,7 +116,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
return html`
<uui-box class=${`breakdown-card ${card.span === "wide" ? "wide" : ""}`}>
<div class="breakdown-card-layout">
<vercel-analytics-breakdown-table
<web-analytics-breakdown-table
.headline=${selected.headline}
.dimension=${selected.dimension}
.metric=${this.metric}
Expand All @@ -124,8 +128,8 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
.linkValues=${linkValues}
.unavailable=${unavailable}>
${card.kind === "tabbed-breakdown" ? this.#renderTabs(card) : ""}
</vercel-analytics-breakdown-table>
${planLimited && unavailable ? html`<p class="hint breakdown-hint">UTM reporting availability depends on your Vercel plan and reporting window.</p>` : ""}
</web-analytics-breakdown-table>
${planLimited && unavailable ? html`<p class="hint breakdown-hint">UTM reporting availability depends on your analytics plan and reporting window.</p>` : ""}
<footer class="breakdown-footer">
${!loading && !unavailable && rows.length ? html`
<uui-button look="secondary" label=${`View all ${selected.headline}`} @click=${() => this.#dispatch("view-breakdown", selected)}>View all</uui-button>
Expand Down Expand Up @@ -154,7 +158,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
return html`
<uui-box class="breakdown-card wide">
<div class="breakdown-card-layout">
<vercel-analytics-breakdown-table
<web-analytics-breakdown-table
.headline=${selected.headline}
.dimension=${selected.dimension}
.metric=${this.metric}
Expand All @@ -167,7 +171,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
.unavailable=${unavailable}>
${this.#renderAcquisitionTabs(utmAvailable)}
${showingUtm ? this.#renderUtmTabs(utmCard) : ""}
</vercel-analytics-breakdown-table>
</web-analytics-breakdown-table>
<footer class="breakdown-footer">
${!loading && !unavailable && rows.length ? html`
<uui-button look="secondary" label=${`View all ${selected.headline}`} @click=${() => this.#dispatch("view-breakdown", selected)}>View all</uui-button>
Expand All @@ -187,7 +191,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
return html`
<uui-box class="breakdown-card wide">
<div class=${`breakdown-card-layout${empty ? " empty-card-layout" : ""}`}>
<vercel-analytics-event-table .rows=${rows} .filters=${this.filters} .loading=${loading}></vercel-analytics-event-table>
<web-analytics-event-table .rows=${rows} .filters=${this.filters} .loading=${loading} .detailsEnabled=${this.supportsEventDetails} .filteringEnabled=${this.supportsGlobalEventFiltering}></web-analytics-event-table>
${empty ? "" : html`<footer class="breakdown-footer">
${!loading && rows.length ? html`<uui-button look="secondary" label="View all events" @click=${() => this.#dispatch("view-events")}>View all</uui-button>` : ""}
</footer>`}
Expand All @@ -199,19 +203,19 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
render() {
const standardCards = this.cards.filter((card) => card.kind !== "tabbed-breakdown" || card.id !== "utm");
const utmCard = this.cards.find((card): card is Extract<DashboardCard, { kind: "tabbed-breakdown" }> => card.kind === "tabbed-breakdown" && card.id === "utm");
const referrerCard = standardCards.find((card) => card.kind === "breakdown" && card.dimension === "ReferrerHostname");
const referrerCard = standardCards.find((card) => card.kind === "breakdown" && (card.dimension === "ReferrerHostname" || card.dimension === "Referrer"));
const renderCard = (card: DashboardCard) => card === referrerCard ? this.#renderAcquisitionCard(card, utmCard) : this.#renderCard(card);
const documentScoped = !standardCards.some((card) => card.kind === "breakdown" && card.dimension === "RequestPath");
const cardsBeforeEvents = documentScoped ? standardCards.slice(0, 1) : standardCards;
const cardsAfterEvents = documentScoped ? standardCards.slice(1) : [];
return html`
<section class="grid" aria-label="Traffic breakdowns">
${cardsBeforeEvents.map(renderCard)}
${this.#renderEvents()}
${this.supportsEvents ? this.#renderEvents() : ""}
${cardsAfterEvents.map(renderCard)}
<uui-box class=${`breakdown-card flags-card${documentScoped ? " document-flags-card" : " wide"}`}>
<vercel-analytics-flag-card .report=${this.flags} .selected=${this.selectedFlag}></vercel-analytics-flag-card>
</uui-box>
${this.supportsFlags ? html`<uui-box class=${`breakdown-card flags-card${documentScoped ? " document-flags-card" : " wide"}`}>
<web-analytics-flag-card .report=${this.flags} .selected=${this.selectedFlag}></web-analytics-flag-card>
</uui-box>` : ""}
</section>
`;
}
Expand Down Expand Up @@ -249,4 +253,4 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
`];
}

declare global { interface HTMLElementTagNameMap { "vercel-analytics-breakdown-grid": VercelAnalyticsBreakdownGridElement; } }
declare global { interface HTMLElementTagNameMap { "web-analytics-breakdown-grid": WebAnalyticsBreakdownGridElement; } }
Loading