diff --git a/apps/web/src/components/map/MapFooter.test.tsx b/apps/web/src/components/map/MapFooter.test.tsx
index d1cad1f3c..0585e83ec 100644
--- a/apps/web/src/components/map/MapFooter.test.tsx
+++ b/apps/web/src/components/map/MapFooter.test.tsx
@@ -62,9 +62,8 @@ it("shows one captured MapTiler credit across owners while earlier basemap credi
expect(
notices.getByRole("link", { name: "OpenStreetMap contributors" }).getAttribute("href"),
).toBe("https://www.openstreetmap.org/copyright");
- expect(notices.getByRole("link", { name: "Licence not specified" }).getAttribute("href")).toBe(
- "https://www.maptiler.com/terms/cloud/",
- );
+ // MapTiler states no licence for its material, so its credit names none.
+ expect(notices.queryByText(/Licence not specified/)).toBeNull();
act(() => useMapAttributionStore.setState({ byLayer: { base: [osm] } }));
expect(notices.queryByRole("link", { name: "MapTiler" })).toBeNull();
});
diff --git a/apps/web/src/components/ui/AttributionDetails.test.tsx b/apps/web/src/components/ui/AttributionDetails.test.tsx
index 5f4bcdd97..0a526a34c 100644
--- a/apps/web/src/components/ui/AttributionDetails.test.tsx
+++ b/apps/web/src/components/ui/AttributionDetails.test.tsx
@@ -163,11 +163,11 @@ it("uses the supplied linked source credit as the heading without repeating its
});
it.each([
- ["en", "Varies by item", "Licence not specified"],
- ["de", "Abhängig vom Eintrag", "Lizenz nicht angegeben"],
+ ["en", "Varies by item", "Licence not specified", "Terms"],
+ ["de", "Abhängig vom Eintrag", "Lizenz nicht angegeben", "Nutzungsbedingungen"],
] as const)(
"uses concise generic licence labels in %s while preserving required notices and links",
- (locale, perItem, unknown) => {
+ (locale, perItem, unknown, terms) => {
const generic = "Determined by each contributing item or operator";
const dialog = show(
{
@@ -188,6 +188,11 @@ it.each([
conditions: ["Check each image's permission"],
},
{ scope: "data", subject: "Unknown dataset", license: { kind: "unknown" } },
+ {
+ scope: "data",
+ subject: "Catalogue entry",
+ license: { kind: "unknown", raw: "todo", url: "https://operator.example/terms" },
+ },
],
},
},
@@ -197,7 +202,14 @@ it.each([
"href",
"https://operator.example/licensing",
);
- expect(dialog.getByText(unknown)).toBeVisible();
+ // A licence that names nothing is left out, or shown as its terms link.
+ expect(dialog.queryByText(unknown)).toBeNull();
+ expect(dialog.queryByText("Unknown dataset")).toBeNull();
+ expect(dialog.queryByText("todo")).toBeNull();
+ expect(dialog.getByRole("link", { name: terms })).toHaveAttribute(
+ "href",
+ "https://operator.example/terms",
+ );
expect(dialog.queryByText(generic)).toBeNull();
expect(dialog.getByText("Credit the individual photographer")).toBeVisible();
expect(dialog.getByText("Check each image's permission")).toBeVisible();
diff --git a/apps/web/src/components/ui/AttributionEntry.tsx b/apps/web/src/components/ui/AttributionEntry.tsx
index 994166fce..00fa7bbb8 100644
--- a/apps/web/src/components/ui/AttributionEntry.tsx
+++ b/apps/web/src/components/ui/AttributionEntry.tsx
@@ -7,6 +7,8 @@ import { safeHref } from "@openmapx/core";
import {
type AttributionDetail,
buildRuntimeAttributionHtml,
+ creditLicensing,
+ isUnstatedLicence,
parseAttributionHtml,
sanitizeAttributionHtml,
} from "@openmapx/core/attribution";
@@ -20,7 +22,8 @@ export function AttributionEntry({ entry }: { entry: AttributionDetail }) {
const attr = entry.attribution;
const readable = (html: string) => sanitizeAttributionHtml(html, { urlLabel: t("sourceLink") });
if (!attr) return ;
- const licensing = attr.licensing;
+ // A grant naming no licence shows only as a link to its source's terms.
+ const licensing = attr.licensing && creditLicensing(attr.licensing, { keepLinked: true });
// Repeated subjects can have different descriptors; preserve their exact positions.
const bindings = licensing
? licensingLinks(licensing).map((binding, index) => ({
@@ -84,11 +87,7 @@ export function AttributionEntry({ entry }: { entry: AttributionDetail }) {
descriptor.raw === "Determined by each contributing item or operator")
)
return t("licenceVariesByItem");
- if (
- descriptor?.kind === "unknown" &&
- (!descriptor.raw || descriptor.raw === "Licence not specified")
- )
- return t("licenceNotSpecified");
+ if (descriptor && isUnstatedLicence(descriptor)) return t("terms");
return binding.label;
};
const grant = (binding: (typeof bindings)[number], showSubject: boolean) => (
diff --git a/apps/web/src/integration-api/map/popupCard.ts b/apps/web/src/integration-api/map/popupCard.ts
index 2317acfde..c2fa5170c 100644
--- a/apps/web/src/integration-api/map/popupCard.ts
+++ b/apps/web/src/integration-api/map/popupCard.ts
@@ -1,4 +1,4 @@
-import { escapeHtml } from "@openmapx/core";
+import { creditLicensing, escapeHtml } from "@openmapx/core";
import {
DISPLAY_LICENSE_SCOPES,
formatLicensing,
@@ -98,7 +98,8 @@ function attributionText(raw: unknown): string {
if (raw != null && typeof raw === "object") {
const o = raw as Record;
const provider = typeof o.provider === "string" ? o.provider : "";
- const licensing = parseLicensing(o.licensing);
+ const parsed = parseLicensing(o.licensing);
+ const licensing = parsed && creditLicensing(parsed);
const license = licensing ? formatLicensing(licensing, { scopes: DISPLAY_LICENSE_SCOPES }) : "";
return provider && license ? `${provider} · ${license}` : provider || license;
}
diff --git a/apps/web/src/lib/attributionCredits.ts b/apps/web/src/lib/attributionCredits.ts
index 3ba87e184..04f069353 100644
--- a/apps/web/src/lib/attributionCredits.ts
+++ b/apps/web/src/lib/attributionCredits.ts
@@ -1,4 +1,4 @@
-import { safeHref } from "@openmapx/core";
+import { creditLicensing, safeHref } from "@openmapx/core";
import type { Attribution } from "@openmapx/mobility-core/attribution";
import { contributorKey } from "@openmapx/mobility-core/contributors";
import {
@@ -50,8 +50,9 @@ const MAX_CHIP_LICENSE_LENGTH = 24;
/** A receipt's licence as a short label, or undefined without licensing. */
export function creditLicense(attr: Attribution): string | undefined {
- return attr.licensing
- ? formatLicensing(attr.licensing, { scopes: DISPLAY_LICENSE_SCOPES }) || undefined
+ const licensing = attr.licensing && creditLicensing(attr.licensing);
+ return licensing
+ ? formatLicensing(licensing, { scopes: DISPLAY_LICENSE_SCOPES }) || undefined
: undefined;
}
diff --git a/integrations/transit-dynamic-registry/index.ts b/integrations/transit-dynamic-registry/index.ts
index 1e0718e3f..b15d555da 100644
--- a/integrations/transit-dynamic-registry/index.ts
+++ b/integrations/transit-dynamic-registry/index.ts
@@ -28,7 +28,10 @@ function buildAttributionFor(
name: entryAttribution.name,
url: entryAttribution.homepage,
licensing: licensingFromDeclaration(
- entryAttribution.license,
+ // The catalogue writes "todo" where an entry's licence is yet to be found.
+ /^todo$/i.test(entryAttribution.license?.trim() ?? "")
+ ? undefined
+ : entryAttribution.license,
undefined,
entryAttribution.name,
),
diff --git a/packages/core/src/utils/attribution-presentation.test.ts b/packages/core/src/utils/attribution-presentation.test.ts
index 597cc178a..2a0c22935 100644
--- a/packages/core/src/utils/attribution-presentation.test.ts
+++ b/packages/core/src/utils/attribution-presentation.test.ts
@@ -266,3 +266,31 @@ it("retains ambiguous repeated-label notices when their link targets differ", ()
expect(result.visible).toHaveLength(2);
expect(result.fullHtml).toEqual([repeatedLabelNotice, other]);
});
+
+describe("credits without an unstated licence", () => {
+ const credit = (license: Record) =>
+ attributionHtml.buildRuntimeAttributionHtml({
+ sourceId: "kvb",
+ name: "Kölner Verkehrs-Betriebe AG",
+ url: "https://kvb.koeln/",
+ licensing: {
+ version: 1,
+ bindings: [{ scope: "data", subject: "KVB", license }],
+ } as never,
+ });
+
+ it("names the source alone when its licence is unknown", () => {
+ expect(credit({ kind: "unknown" })).toBe(
+ 'Kölner Verkehrs-Betriebe AG',
+ );
+ });
+
+ it("treats a catalogue's placeholder as no licence", () => {
+ expect(credit({ kind: "unknown", raw: "todo" })).not.toContain("todo");
+ });
+
+ it("keeps a licence the source names", () => {
+ expect(credit({ kind: "spdx", expression: "CC-BY-4.0" })).toContain("CC-BY-4.0");
+ expect(credit({ kind: "unknown", raw: "Proprietary" })).toContain("Proprietary");
+ });
+});
diff --git a/packages/core/src/utils/attribution.ts b/packages/core/src/utils/attribution.ts
index 8eca6288b..8ca781252 100644
--- a/packages/core/src/utils/attribution.ts
+++ b/packages/core/src/utils/attribution.ts
@@ -175,6 +175,34 @@ export function buildAttributionHtml(ds: {
});
}
+/** Placeholders a catalogue leaves where its licence is yet to be filled in. */
+const LICENCE_PLACEHOLDER =
+ /^(?:todo|tbd|tba|n\/?a|-|\?+|licence not specified|license not specified)$/i;
+
+/** Whether a grant names no licence: unknown, and without a licence name of its own. */
+export function isUnstatedLicence(license: Licensing["bindings"][number]["license"]): boolean {
+ return (
+ license.kind === "unknown" &&
+ (!license.raw?.trim() || LICENCE_PLACEHOLDER.test(license.raw.trim()))
+ );
+}
+
+/**
+ * The licensing a credit shows: without grants that name no licence. "Licence
+ * not specified" beside a source tells a reader nothing, so the credit names
+ * the source alone. With `keepLinked`, an unstated grant that links its source's
+ * terms stays, for views that can label that link.
+ */
+export function creditLicensing(
+ licensing: Licensing,
+ options: { keepLinked?: boolean } = {},
+): Licensing | undefined {
+ const bindings = licensing.bindings.filter(
+ (binding) => !isUnstatedLicence(binding.license) || (options.keepLinked && binding.license.url),
+ );
+ return bindings.length > 0 ? { ...licensing, bindings } : undefined;
+}
+
/** Render all applicable subjects and grants using the shared licence registry. */
export function buildLicensingHtml(
licensing: Licensing,
@@ -214,7 +242,8 @@ export function buildRuntimeAttributionHtml(
const credit = attribution.attributionText
? sanitizeAttributionHtml(attribution.attributionText)
: nameLink;
- const licensing = attribution.licensing ? buildLicensingHtml(attribution.licensing, options) : "";
+ const shown = attribution.licensing && creditLicensing(attribution.licensing);
+ const licensing = shown ? buildLicensingHtml(shown, options) : "";
return licensing ? `${credit} (${licensing})` : credit;
}
diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts
index b2595a321..7d0aaa46f 100644
--- a/packages/core/src/utils/index.ts
+++ b/packages/core/src/utils/index.ts
@@ -13,7 +13,9 @@ export {
buildAttributionPresentation,
buildLicensingHtml,
buildRuntimeAttributionHtml,
+ creditLicensing,
extractSourcePrefix,
+ isUnstatedLicence,
parseAttributionHtml,
pickIntegrationForSources,
sanitizeAttributionHtml,
diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json
index 2dd401271..67ae659c9 100644
--- a/packages/i18n/locales/de.json
+++ b/packages/i18n/locales/de.json
@@ -388,7 +388,6 @@
},
"footer": {
"licenceVariesByItem": "Abhängig vom Eintrag",
- "licenceNotSpecified": "Lizenz nicht angegeben",
"termsOfUse": "Nutzungsbedingungen",
"sourceLink": "Quelle",
"additionalSource": "Originalquelle",
diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json
index b3a2fb0b0..900c5e289 100644
--- a/packages/i18n/locales/en.json
+++ b/packages/i18n/locales/en.json
@@ -388,7 +388,6 @@
},
"footer": {
"licenceVariesByItem": "Varies by item",
- "licenceNotSpecified": "Licence not specified",
"termsOfUse": "Terms of use",
"sourceLink": "Source",
"additionalSource": "Original source",