@datagouv/components-next 1.3.0 → 1.4.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 (131) hide show
  1. package/dist/{Control-b-43UT2B.js → Control-B9WQxlpJ.js} +10 -10
  2. package/dist/{Datafair.client-BrjISlaZ.js → Datafair.client-D_6yzJUG.js} +9 -9
  3. package/dist/{Event-py_AAwJY.js → Event-iXDrp-fy.js} +15 -15
  4. package/dist/{Image-BHQYZWzH.js → Image-mNMBOofs.js} +22 -22
  5. package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
  6. package/dist/{JsonPreview.client-DivFAnbN.js → JsonPreview.client-DSl1kiwC.js} +2 -2
  7. package/dist/{Map-CtP7leHl.js → Map-BfqKMw0S.js} +165 -165
  8. package/dist/{MapContainer.client-Cq8tNLZZ.js → MapContainer.client-BSfpk_rT.js} +17 -17
  9. package/dist/{OSM-gdDSIsDA.js → OSM-CcrUhItr.js} +1 -1
  10. package/dist/{PdfPreview.client-BNBgLsK2.js → PdfPreview.client-BANFeM6K.js} +438 -427
  11. package/dist/{Pmtiles.client-CQi5Rw8W.js → Pmtiles.client-DH9ZrsZR.js} +6177 -5967
  12. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
  13. package/dist/{ScaleLine-hG-FhNqy.js → ScaleLine-VGQ7nkMS.js} +2 -2
  14. package/dist/{Tile-D6rFonpT.js → Tile-CSpn0Ca8.js} +47 -47
  15. package/dist/{TileImage-cfrEgz5y.js → TileImage-CpbaEFwd.js} +44 -44
  16. package/dist/{View-vp7vDb9q.js → View-BYBpt-AU.js} +58 -58
  17. package/dist/{XmlPreview.client-XCsNvnsZ.js → XmlPreview.client-KCB5mwVf.js} +3 -3
  18. package/dist/{common-C8efyyuc.js → common-BoeTUnDZ.js} +58 -58
  19. package/dist/components-next.css +1 -1
  20. package/dist/components-next.js +199 -172
  21. package/dist/components.css +1 -1
  22. package/dist/{index-CXUamc8Z.js → index-DHWzX5vK.js} +4668 -4466
  23. package/dist/{leaflet-src-BRKv9Ksv.js → leaflet-src-pVQfonBI.js} +1 -1
  24. package/dist/{main-DKu5SOAl.js → main-BDcYkhOc.js} +41684 -33432
  25. package/dist/{proj-CsjVeojN.js → proj-DxGbobp7.js} +110 -110
  26. package/dist/{tilecoord-CPcQJnrF.js → tilecoord-DvX7WWiA.js} +94 -94
  27. package/dist/{vue3-xml-viewer.common-BIaEIFYG.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
  28. package/package.json +28 -27
  29. package/src/components/ActivityList/ActivityList.vue +21 -12
  30. package/src/components/AppLink.vue +3 -0
  31. package/src/components/BannerAction.vue +6 -2
  32. package/src/components/Chart/ChartViewer.vue +111 -17
  33. package/src/components/Chart/ChartViewerWrapper.vue +28 -16
  34. package/src/components/ChartCard.vue +112 -0
  35. package/src/components/CopyButton.vue +6 -2
  36. package/src/components/DataserviceCard.vue +23 -6
  37. package/src/components/DataserviceQuality.vue +82 -0
  38. package/src/components/DataserviceQualityInline.vue +31 -0
  39. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  40. package/src/components/DatasetCard.vue +17 -6
  41. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
  42. package/src/components/DatasetQuality.vue +11 -11
  43. package/src/components/DatasetQualityInline.vue +2 -2
  44. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  45. package/src/components/DiscussionMessageCard.vue +14 -15
  46. package/src/components/Form/SchemaSelect.vue +2 -0
  47. package/src/components/FormattedDate.vue +69 -0
  48. package/src/components/LoadingBlock.vue +4 -1
  49. package/src/components/ObjectCard.vue +4 -1
  50. package/src/components/ObjectCardOwner.vue +13 -1
  51. package/src/components/Pagination.vue +18 -58
  52. package/src/components/Placeholder.vue +3 -2
  53. package/src/components/PostCard.vue +13 -15
  54. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  55. package/src/components/ResourceAccordion/Downloads.vue +19 -7
  56. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  57. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
  58. package/src/components/ResourceAccordion/Metadata.vue +21 -5
  59. package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
  60. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  61. package/src/components/ResourceAccordion/Pmtiles.client.vue +27 -18
  62. package/src/components/ResourceAccordion/Preview.vue +8 -4
  63. package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
  64. package/src/components/ResourceAccordion/ResourceAccordion.vue +29 -9
  65. package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -209
  66. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
  67. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
  68. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +136 -183
  69. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  70. package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
  71. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
  72. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
  73. package/src/components/ResourceListItem.vue +168 -0
  74. package/src/components/ReuseCard.vue +18 -6
  75. package/src/components/ReuseDetails.vue +2 -2
  76. package/src/components/ReuseHorizontalCard.vue +4 -3
  77. package/src/components/SchemaCard.vue +10 -1
  78. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  79. package/src/components/Search/GlobalSearch.vue +12 -8
  80. package/src/components/Search/SearchInput.vue +1 -1
  81. package/src/components/Search/Sidemenu.vue +4 -3
  82. package/src/components/SmallChart.vue +11 -10
  83. package/src/components/StatBox.vue +13 -5
  84. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  85. package/src/components/TabularExplorer/TabularCell.vue +7 -8
  86. package/src/components/TabularExplorer/TabularCellPopover.vue +19 -3
  87. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  88. package/src/components/TabularExplorer/TabularDateField.vue +41 -0
  89. package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
  90. package/src/components/TabularExplorer/TabularExplorer.vue +122 -913
  91. package/src/components/TabularExplorer/TabularFilterContent.vue +33 -25
  92. package/src/components/TabularExplorer/TabularFilterPopover.vue +7 -7
  93. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  94. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  95. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  96. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  97. package/src/components/TabularExplorer/TabularTable.vue +484 -0
  98. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  99. package/src/components/TabularExplorer/types.ts +32 -20
  100. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  101. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  102. package/src/components/TopicCard.vue +15 -14
  103. package/src/composables/useActiveDescendant.ts +5 -0
  104. package/src/composables/useDatasetResources.ts +164 -0
  105. package/src/composables/useHasTabularData.ts +2 -1
  106. package/src/composables/useResizable.ts +46 -0
  107. package/src/composables/useResourceCapabilities.ts +47 -39
  108. package/src/composables/useTranslation.ts +24 -9
  109. package/src/config.ts +1 -0
  110. package/src/functions/charts.ts +32 -1
  111. package/src/functions/dates.ts +26 -7
  112. package/src/functions/helpers.ts +15 -0
  113. package/src/functions/metrics.ts +57 -56
  114. package/src/functions/owned.ts +21 -0
  115. package/src/functions/paginate.ts +46 -0
  116. package/src/functions/resourceCapabilities.ts +34 -0
  117. package/src/functions/resources.ts +83 -0
  118. package/src/functions/schemas.ts +27 -0
  119. package/src/functions/scroll.ts +20 -0
  120. package/src/functions/tabular.ts +119 -4
  121. package/src/functions/tabularApi.ts +1 -43
  122. package/src/main.ts +32 -6
  123. package/src/types/dataservices.ts +15 -0
  124. package/src/types/organizations.ts +10 -0
  125. package/src/types/reuses.ts +12 -1
  126. package/src/types/search.ts +2 -2
  127. package/src/types/visualizations.ts +5 -0
  128. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js +0 -61
  129. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  130. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  131. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -2,6 +2,26 @@ import { useTranslation } from '../composables/useTranslation'
2
2
 
3
3
  const SECONDS_IN_A_DAY = 3600 * 24
4
4
 
5
+ /**
6
+ * `2026`, `2026-04` and `2026-04-24` carry no time of day. `new Date()` reads them as
7
+ * midnight UTC, an instant they never claimed, and then converts it: a reader west of
8
+ * UTC gets the day before — and with it the month, and sometimes the year.
9
+ */
10
+ export const PLAIN_DATE = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/
11
+
12
+ /**
13
+ * A date value read as the day it names. A plain date is built in local time, where no
14
+ * conversion can move it; anything else is an instant and keeps being read as one.
15
+ */
16
+ export function parseDateValue(value: Date | string | null | undefined): Date | null {
17
+ if (!value) return null
18
+ const plain = typeof value === 'string' ? value.match(PLAIN_DATE) : null
19
+ const date = plain
20
+ ? new Date(Number(plain[1]), Number(plain[2] ?? 1) - 1, Number(plain[3] ?? 1))
21
+ : new Date(value)
22
+ return isNaN(date.getTime()) ? null : date
23
+ }
24
+
5
25
  export function useFormatDate() {
6
26
  const { t, locale } = useTranslation()
7
27
 
@@ -9,11 +29,9 @@ export function useFormatDate() {
9
29
  if (!date) {
10
30
  return ''
11
31
  }
12
- date = new Date(date)
13
- if (!('dateStyle' in options)) {
14
- options.dateStyle = 'long'
15
- }
16
- return new Intl.DateTimeFormat(locale, options).format(date)
32
+ // An explicit `dateStyle: undefined` overrides the default, which is how callers ask
33
+ // for `year`/`month` components — `dateStyle` and those cannot be combined.
34
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', ...options }).format(new Date(date))
17
35
  }
18
36
 
19
37
  /**
@@ -77,8 +95,9 @@ export function useFormatDate() {
77
95
  dateWithoutTime.setHours(0)
78
96
  dateWithoutTime.setMinutes(0)
79
97
  dateWithoutTime.setSeconds(0)
80
- const diff = Math.abs(dateWithoutTime.getTime() - today.getTime())
81
- if (Math.round(diff / (SECONDS_IN_A_DAY * 30)) >= 1) {
98
+ // `getTime()` returns milliseconds, `SECONDS_IN_A_DAY` is in seconds
99
+ const diffInSeconds = Math.abs(dateWithoutTime.getTime() - today.getTime()) / 1000
100
+ if (diffInSeconds >= SECONDS_IN_A_DAY * 30) {
82
101
  return t('le {date}', { date: formatDate(date, options) })
83
102
  }
84
103
  return formatFromNow(date)
@@ -38,6 +38,21 @@ export const summarize = (val: number, fractionDigits = 0) => {
38
38
  return `${toFixedIfNotZero(val)}Y`
39
39
  }
40
40
 
41
+ // Guards any value that ends up in an `href`: only absolute http(s) URLs are allowed.
42
+ // Some of these URLs come from resource extras, which are freely writable by the
43
+ // resource owner, so an unguarded value could carry a `javascript:` or `data:` scheme
44
+ // and execute in the platform origin when a visitor clicks the link.
45
+ export const isSafeHttpUrl = (value: unknown): value is string => {
46
+ if (typeof value !== 'string') return false
47
+ try {
48
+ const { protocol } = new URL(value)
49
+ return protocol === 'http:' || protocol === 'https:'
50
+ }
51
+ catch {
52
+ return false
53
+ }
54
+ }
55
+
41
56
  export const escapeCsvValue = (value: string | number | null | undefined): string => {
42
57
  if (value === null || value === undefined || value === '') {
43
58
  return ''
@@ -31,49 +31,73 @@ export type ReuseMetrics = {
31
31
  visitsTotal: number
32
32
  }
33
33
 
34
+ type MonthlyVisitsRow = { metric_month: string, monthly_visit: number }
35
+ type MonthlyDatasetRow = MonthlyVisitsRow & { monthly_download_resource: number }
36
+ type MonthlyOrganizationRow = {
37
+ metric_month: string
38
+ monthly_download_resource: number
39
+ monthly_visit_dataservice: number
40
+ monthly_visit_dataset: number
41
+ monthly_visit_reuse: number
42
+ }
43
+
44
+ type TotalVisitsRow = { visit: number }
45
+ type TotalDatasetRow = TotalVisitsRow & { download_resource: number }
46
+ type TotalOrganizationRow = {
47
+ download_resource: number
48
+ visit_dataservice: number
49
+ visit_dataset: number
50
+ visit_reuse: number
51
+ }
52
+
53
+ /**
54
+ * The metrics API is a separate service that answers over the network, so a call fails in
55
+ * three ways: the request itself, an error status, or a body that isn't the expected page
56
+ * of rows. Rejecting on the last two turns them into the same failure as the first, which
57
+ * callers already hand to `useAsyncData` — reading `page.data` straight would instead throw
58
+ * a `TypeError` a few lines later, naming a symptom rather than the service.
59
+ */
60
+ async function fetchMetrics<Row>(url: string): Promise<Array<Row>> {
61
+ const response = await fetch(url)
62
+ if (!response.ok) throw new Error(`The metrics API answered ${response.status} for ${url}`)
63
+
64
+ const page: { data?: Array<Row> } = await response.json()
65
+ if (!Array.isArray(page?.data)) throw new Error(`The metrics API answered an unexpected body for ${url}`)
66
+
67
+ return page.data
68
+ }
69
+
34
70
  /**
35
71
  * There is only one metrics API endpoint to get these 3 values.
36
72
  * The rest of the metrics aren't stored yet at the organization level
37
73
  */
38
74
  export async function getOrganizationMetrics(oid: string, metricsApi: string): Promise<OrganizationMetrics> {
39
75
  // Fetching last 12 months
40
- const response = await fetch(`${metricsApi}/api/organizations/data/?organization_id__exact=${oid}&metric_month__sort=desc&page_size=12`)
41
- const page = await response.json()
76
+ const rows = await fetchMetrics<MonthlyOrganizationRow>(`${metricsApi}/api/organizations/data/?organization_id__exact=${oid}&metric_month__sort=desc&page_size=12`)
42
77
 
43
78
  const dataservicesViews: Record<string, number> = {}
44
79
  const datasetsViews: Record<string, number> = {}
45
80
  const downloads: Record<string, number> = {}
46
81
  const reusesViews: Record<string, number> = {}
47
82
 
48
- for (const { metric_month, monthly_download_resource, monthly_visit_dataservice, monthly_visit_dataset, monthly_visit_reuse } of page.data) {
83
+ for (const { metric_month, monthly_download_resource, monthly_visit_dataservice, monthly_visit_dataset, monthly_visit_reuse } of rows) {
49
84
  dataservicesViews[metric_month] = monthly_visit_dataservice
50
85
  datasetsViews[metric_month] = monthly_visit_dataset
51
86
  downloads[metric_month] = monthly_download_resource
52
87
  reusesViews[metric_month] = monthly_visit_reuse
53
88
  }
54
89
  // Fetching totals
55
- const totalResponse = await fetch(`${metricsApi}/api/organizations_total/data/?organization_id__exact=${oid}`)
56
- const totalPage = await totalResponse.json()
57
-
58
- let dataservicesViewsTotal = 0
59
- let datasetsViewsTotal = 0
60
- let downloadsTotal = 0
61
- let reusesViewsTotal = 0
62
- if (page.data[0]) {
63
- dataservicesViewsTotal = totalPage.data[0].visit_dataservice
64
- datasetsViewsTotal = totalPage.data[0].visit_dataset
65
- downloadsTotal = totalPage.data[0].download_resource
66
- reusesViewsTotal = totalPage.data[0].visit_reuse
67
- }
90
+ const total = (await fetchMetrics<TotalOrganizationRow>(`${metricsApi}/api/organizations_total/data/?organization_id__exact=${oid}`))[0]
91
+
68
92
  return {
69
93
  downloads,
70
- downloadsTotal,
94
+ downloadsTotal: total?.download_resource ?? 0,
71
95
  reusesViews,
72
- reusesViewsTotal,
96
+ reusesViewsTotal: total?.visit_reuse ?? 0,
73
97
  dataservicesViews,
74
- dataservicesViewsTotal,
98
+ dataservicesViewsTotal: total?.visit_dataservice ?? 0,
75
99
  datasetsViews,
76
- datasetsViewsTotal,
100
+ datasetsViewsTotal: total?.visit_dataset ?? 0,
77
101
  }
78
102
  }
79
103
 
@@ -89,33 +113,24 @@ export function createOrganizationMetricsUrl(datasetsViews: Record<string, numbe
89
113
 
90
114
  export async function getDatasetMetrics(datasetId: string, metricsApi: string): Promise<DatasetMetrics> {
91
115
  // Fetching last 12 months
92
- const response = await fetch(`${metricsApi}/api/datasets/data/?dataset_id__exact=${datasetId}&metric_month__sort=desc&page_size=12`)
93
- const page = await response.json()
116
+ const rows = await fetchMetrics<MonthlyDatasetRow>(`${metricsApi}/api/datasets/data/?dataset_id__exact=${datasetId}&metric_month__sort=desc&page_size=12`)
94
117
 
95
118
  const visits: Record<string, number> = {}
96
119
  const downloads: Record<string, number> = {}
97
120
 
98
- for (const { metric_month, monthly_visit, monthly_download_resource } of page.data) {
121
+ for (const { metric_month, monthly_visit, monthly_download_resource } of rows) {
99
122
  visits[metric_month] = monthly_visit
100
123
  downloads[metric_month] = monthly_download_resource
101
124
  }
102
125
 
103
126
  // Fetching totals
104
- const totalResponse = await fetch(`${metricsApi}/api/datasets_total/data/?dataset_id__exact=${datasetId}`)
105
- const totalPage = await totalResponse.json()
106
-
107
- let visitsTotal = 0
108
- let downloadsTotal = 0
109
- if (totalPage.data[0]) {
110
- visitsTotal = totalPage.data[0].visit
111
- downloadsTotal = totalPage.data[0].download_resource
112
- }
127
+ const total = (await fetchMetrics<TotalDatasetRow>(`${metricsApi}/api/datasets_total/data/?dataset_id__exact=${datasetId}`))[0]
113
128
 
114
129
  return {
115
130
  visits,
116
- visitsTotal,
131
+ visitsTotal: total?.visit ?? 0,
117
132
  downloads,
118
- downloadsTotal,
133
+ downloadsTotal: total?.download_resource ?? 0,
119
134
  }
120
135
  }
121
136
 
@@ -151,52 +166,38 @@ export async function createDatasetsForOrganizationMetricsUrl(organizationId: st
151
166
 
152
167
  export async function getDataserviceMetrics(dataserviceId: string, metricsApi: string): Promise<DataserviceMetrics> {
153
168
  // Fetching last 12 months
154
- const response = await fetch(`${metricsApi}/api/dataservices/data/?dataservice_id__exact=${dataserviceId}&metric_month__sort=desc&page_size=12`)
155
- const page = await response.json()
169
+ const rows = await fetchMetrics<MonthlyVisitsRow>(`${metricsApi}/api/dataservices/data/?dataservice_id__exact=${dataserviceId}&metric_month__sort=desc&page_size=12`)
156
170
 
157
171
  const visits: Record<string, number> = {}
158
172
 
159
- for (const { metric_month, monthly_visit } of page.data) {
173
+ for (const { metric_month, monthly_visit } of rows) {
160
174
  visits[metric_month] = monthly_visit
161
175
  }
162
176
 
163
177
  // Fetching totals
164
- const totalResponse = await fetch(`${metricsApi}/api/dataservices_total/data/?dataservice_id__exact=${dataserviceId}`)
165
- const totalPage = await totalResponse.json()
166
-
167
- let visitsTotal = 0
168
- if (totalPage.data[0]) {
169
- visitsTotal = totalPage.data[0].visit
170
- }
178
+ const total = (await fetchMetrics<TotalVisitsRow>(`${metricsApi}/api/dataservices_total/data/?dataservice_id__exact=${dataserviceId}`))[0]
171
179
 
172
180
  return {
173
181
  visits,
174
- visitsTotal,
182
+ visitsTotal: total?.visit ?? 0,
175
183
  }
176
184
  }
177
185
 
178
186
  export async function getReuseMetrics(reuseId: string, metricsApi: string): Promise<ReuseMetrics> {
179
187
  // Fetching last 12 months
180
- const response = await fetch(`${metricsApi}/api/reuses/data/?reuse_id__exact=${reuseId}&metric_month__sort=desc&page_size=12`)
181
- const page = await response.json()
188
+ const rows = await fetchMetrics<MonthlyVisitsRow>(`${metricsApi}/api/reuses/data/?reuse_id__exact=${reuseId}&metric_month__sort=desc&page_size=12`)
182
189
 
183
190
  const visits: Record<string, number> = {}
184
191
 
185
- for (const { metric_month, monthly_visit } of page.data) {
192
+ for (const { metric_month, monthly_visit } of rows) {
186
193
  visits[metric_month] = monthly_visit
187
194
  }
188
195
 
189
196
  // Fetching totals
190
- const totalResponse = await fetch(`${metricsApi}/api/reuses_total/data/?reuse_id__exact=${reuseId}`)
191
- const totalPage = await totalResponse.json()
192
-
193
- let visitsTotal = 0
194
- if (totalPage.data[0]) {
195
- visitsTotal = totalPage.data[0].visit
196
- }
197
+ const total = (await fetchMetrics<TotalVisitsRow>(`${metricsApi}/api/reuses_total/data/?reuse_id__exact=${reuseId}`))[0]
197
198
 
198
199
  return {
199
200
  visits,
200
- visitsTotal,
201
+ visitsTotal: total?.visit ?? 0,
201
202
  }
202
203
  }
@@ -19,3 +19,24 @@ export function getOwnerPage(owned: Owned): string | null {
19
19
  }
20
20
  return null // Not supposed to exist but it does...
21
21
  }
22
+
23
+ function escapeHtml(value: string): string {
24
+ return value
25
+ .replace(/&/g, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&#39;')
30
+ }
31
+
32
+ export function getMapAttribution(owned: Owned): string {
33
+ const name = getOwnerName(owned)
34
+ const page = getOwnerPage(owned)
35
+ if (!name && !page)
36
+ return ''
37
+ // The owner name is free text any user controls (organization name, or a
38
+ // profile's first/last name). maplibre-gl injects this attribution string
39
+ // into the DOM as HTML, and its own attribution sanitizer is bypassable via
40
+ // mutation XSS, so every interpolated value has to be escaped here.
41
+ return `© <a href="${escapeHtml(page ?? '')}" target="_blank">${escapeHtml(name)}</a>`
42
+ }
@@ -0,0 +1,46 @@
1
+ export const PAGES_AROUND = 3
2
+
3
+ function range(size: number, startAt: number) {
4
+ return [...Array(size).keys()].map(i => i + startAt)
5
+ }
6
+
7
+ /**
8
+ * Brings whatever the caller holds as a current page back to a page that exists.
9
+ * The page number usually comes from a query string, so it can be anything:
10
+ * `?page=abc` (NaN), `?page=-5`, `?page=1.5`, or a stale link to a page far past
11
+ * the last one.
12
+ */
13
+ export function clampPage(page: number, pageCount: number) {
14
+ if (!Number.isFinite(page)) {
15
+ return 1
16
+ }
17
+ return Math.min(Math.max(Math.trunc(page), 1), Math.max(pageCount, 1))
18
+ }
19
+
20
+ /**
21
+ * The middle window of the pagination: the pages around the current one, with
22
+ * `null` marking an ellipsis. The first and last pages are rendered by the
23
+ * component itself and are never part of this window.
24
+ */
25
+ export function getVisiblePages(currentPage: number, pageCount: number) {
26
+ if (pageCount <= 2) {
27
+ return []
28
+ }
29
+
30
+ const page = clampPage(currentPage, pageCount)
31
+ const size = Math.min(
32
+ PAGES_AROUND * 2 + 1,
33
+ pageCount - 2, // the first and last pages are rendered outside of the window
34
+ PAGES_AROUND + page - 1, // don't reach below page 2
35
+ PAGES_AROUND + pageCount - page, // don't reach past the last page
36
+ )
37
+ const pagination: Array<number | null> = range(size, Math.max(page - PAGES_AROUND, 2))
38
+
39
+ if (!pagination.includes(2)) {
40
+ pagination.unshift(null)
41
+ }
42
+ if (!pagination.includes(pageCount - 1)) {
43
+ pagination.push(null)
44
+ }
45
+ return pagination
46
+ }
@@ -1,4 +1,35 @@
1
1
  import type { Resource, WfsMetadata, OgcLayerInfo } from '../types/resources'
2
+ import { isSafeHttpUrl } from './helpers'
3
+
4
+ const GENERATED_FORMATS = ['parquet', 'pmtiles', 'geojson']
5
+
6
+ export type GeneratedFormat = {
7
+ url: string
8
+ size: number | undefined
9
+ format: string
10
+ }
11
+
12
+ export function getGeneratedFormats(resource: Pick<Resource, 'extras' | 'id'>, tabularApiUrl: string | undefined): GeneratedFormat[] {
13
+ const formats: GeneratedFormat[] = GENERATED_FORMATS.flatMap((format) => {
14
+ const url = resource.extras[`analysis:parsing:${format}_url`]
15
+ // The URL is read verbatim from owner-writable extras: drop any entry that is not
16
+ // a plain http(s) URL so a `javascript:`/`data:` value never reaches an href.
17
+ if (!isSafeHttpUrl(url)) return []
18
+ return [{
19
+ url,
20
+ size: resource.extras[`analysis:parsing:${format}_size`] as number | undefined,
21
+ format,
22
+ }]
23
+ })
24
+ if ('analysis:parsing:parsing_table' in resource.extras) {
25
+ formats.push({
26
+ url: `${tabularApiUrl}/api/resources/${resource.id}/data/json/`,
27
+ size: undefined,
28
+ format: 'json',
29
+ })
30
+ }
31
+ return formats
32
+ }
2
33
 
3
34
  const WFS_EXPORT_FORMATS = [
4
35
  {
@@ -46,6 +77,9 @@ export function getWfsExportFormats(resource: Pick<Resource, 'extras' | 'url'>)
46
77
  const outputFormats = wfsMetadata.output_formats.map((format: string) => format.toLowerCase())
47
78
  const layer = wfsMetadata.detected_layer
48
79
  if (!layer) return []
80
+ // Every download URL below is built on top of `resource.url`, so its scheme is the one
81
+ // of `resource.url`: guard it once here rather than on each of the built URLs.
82
+ if (!isSafeHttpUrl(resource.url)) return []
49
83
  const formats = WFS_EXPORT_FORMATS.filter(format => outputFormats.includes(format.mimetype.toLowerCase()))
50
84
  .map(format => ({
51
85
  url: buildWfsDownloadUrl(resource.url, wfsMetadata, format, layer),
@@ -84,6 +84,25 @@ export function getResourceFormatIcon(format: string): Component | null {
84
84
  }
85
85
  }
86
86
 
87
+ // Badge colors for the resource icon, keyed on the same format categorization as
88
+ // getResourceFormatIcon so tabular/geo/code/documentation stay consistent with the icon.
89
+ export function getResourceIconColor(format?: string | null): string {
90
+ const icon = format ? getResourceFormatIcon(format) : null
91
+ switch (icon) {
92
+ case Table:
93
+ return 'bg-[#c3fad5] text-[#18753c]'
94
+ case RiMap2Line:
95
+ case RiEarthLine:
96
+ return 'bg-[#e6eefe] text-[#0063cb]'
97
+ case Code:
98
+ return 'bg-[#fce164] text-[#716043]'
99
+ case Documentation:
100
+ return 'bg-[#fee7fc] text-[#6e445a]'
101
+ default:
102
+ return 'bg-gray-100 text-gray-plain'
103
+ }
104
+ }
105
+
87
106
  export function getResourceTitleId(resource: Resource) {
88
107
  return 'resource-' + resource.id + '-title'
89
108
  }
@@ -105,14 +124,29 @@ export const getResourceLabel = (type: ResourceType, count?: number) => {
105
124
  }
106
125
  return t('Fichiers principaux')
107
126
  case 'documentation':
127
+ if (typeof count === 'number') {
128
+ return t('Aucun fichier de documentation | 1 fichier de documentation | {n} fichiers de documentation', count)
129
+ }
108
130
  return t('Documentation')
109
131
  case 'update':
132
+ if (typeof count === 'number') {
133
+ return t('Aucune mise à jour | 1 mise à jour | {n} mises à jour', count)
134
+ }
110
135
  return t('Mise à jour')
111
136
  case 'api':
137
+ if (typeof count === 'number') {
138
+ return t('Aucune API | 1 API | {n} API', count)
139
+ }
112
140
  return t('API')
113
141
  case 'code':
142
+ if (typeof count === 'number') {
143
+ return t('Aucun fichier de code source | 1 fichier de code source | {n} fichiers de code source', count)
144
+ }
114
145
  return t('Code source')
115
146
  case 'other':
147
+ if (typeof count === 'number') {
148
+ return t('Aucun autre fichier | 1 autre fichier | {n} autres fichiers', count)
149
+ }
116
150
  return t('Autre')
117
151
  }
118
152
  }
@@ -145,6 +179,14 @@ export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Datas
145
179
  return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
146
180
  }
147
181
 
182
+ export function resolveResourceExternalUrl<R extends Resource | CommunityResource>(
183
+ dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>,
184
+ resource: R,
185
+ override?: (resource: R) => string,
186
+ ): string {
187
+ return override ? override(resource) : getResourceExternalUrl(dataset, resource)
188
+ }
189
+
148
190
  export function getResourceFilesize(resource: Resource): null | number {
149
191
  if (resource.filesize) return resource.filesize
150
192
  if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number
@@ -152,6 +194,47 @@ export function getResourceFilesize(resource: Resource): null | number {
152
194
  return null
153
195
  }
154
196
 
197
+ const IMAGE_PREVIEW_FORMATS = ['png', 'jpg', 'jpeg', 'jpe', 'gif', 'webp'] as const
198
+
199
+ export function isImagePreviewFormat(format: string | undefined | null): boolean {
200
+ if (!format) return false
201
+ return IMAGE_PREVIEW_FORMATS.includes(format.toLowerCase() as typeof IMAGE_PREVIEW_FORMATS[number])
202
+ }
203
+
204
+ // Parsing errors are stored as `<step>:<message>`, where `<step>` names the
205
+ // analysis step that failed (e.g. `pmtiles_export`, `geojson_export`).
206
+
207
+ /** The analysis step that failed, or null when there is no parsing error. */
208
+ export function getParsingErrorStep(resource: Resource): string | null {
209
+ const error = resource.extras['analysis:parsing:error']
210
+ if (typeof error !== 'string' || !error) return null
211
+ const [step] = error.split(':', 1)
212
+ return step ?? null
213
+ }
214
+
215
+ /** The parsing error message without its `<step>:` prefix, or null when there is none. */
216
+ export function getParsingErrorMessage(resource: Resource): string | null {
217
+ const error = resource.extras['analysis:parsing:error']
218
+ if (typeof error !== 'string' || !error) return null
219
+ const colon = error.indexOf(':')
220
+ return colon === -1 ? error : error.slice(colon + 1)
221
+ }
222
+
223
+ // Geo/format export steps run *after* the tabular table has been built, so a
224
+ // failure here (e.g. a huge geometry timing out the pmtiles export) leaves the
225
+ // tabular data fully usable and must not disable the tabular explorer.
226
+ const NON_TABULAR_PARSING_ERROR_STEPS = ['pmtiles_export', 'geojson_export']
227
+
228
+ /**
229
+ * Whether the resource's parsing error prevents reading its tabular data.
230
+ * Only errors from the tabular parsing itself are fatal; downstream geo/format
231
+ * export failures are not.
232
+ */
233
+ export function hasTabularParsingError(resource: Resource): boolean {
234
+ const step = getParsingErrorStep(resource)
235
+ return step !== null && !NON_TABULAR_PARSING_ERROR_STEPS.includes(step)
236
+ }
237
+
155
238
  type CorsStatus = 'allowed' | 'blocked' | 'unknown'
156
239
 
157
240
  export const getResourceCorsStatus = (resource: Resource): CorsStatus => {
@@ -31,6 +31,33 @@ export function getSchemaVersion(schema: RegisteredSchema | null) {
31
31
  }
32
32
  }
33
33
 
34
+ // Accents and separators must not stand between a query and a schema: people type
35
+ // "lave linge" for « Lave-linge », or « reparabilite » without its accents
36
+ function normalizeForSearch(value: string): string {
37
+ return value
38
+ .toLowerCase()
39
+ .normalize('NFD')
40
+ .replace(/\p{Diacritic}/gu, '')
41
+ .replace(/[^a-z0-9]+/g, ' ')
42
+ .trim()
43
+ }
44
+
45
+ // The name is searched along with the title and the description: it is the identifier
46
+ // people know and paste, such as `etalab/indice-durabilite-lave-linge`
47
+ const searchableText = (schema: RegisteredSchema) => normalizeForSearch(`${schema.name} ${schema.title ?? ''} ${schema.description ?? ''}`)
48
+
49
+ const queryTokens = (query: string) => normalizeForSearch(query).split(' ').filter(Boolean)
50
+
51
+ export function schemaMatchesQuery(schema: RegisteredSchema, query: string): boolean {
52
+ // A query made only of punctuation holds no word to match. Callers short-circuit the
53
+ // empty query, so matching everything here would only ever answer that case.
54
+ const tokens = queryTokens(query)
55
+ if (!tokens.length) return false
56
+
57
+ const haystack = searchableText(schema)
58
+ return tokens.every(token => haystack.includes(token))
59
+ }
60
+
34
61
  export function findSchemaInCatalog(catalog: Array<RegisteredSchema>, schema: Schema | null): RegisteredSchema | null {
35
62
  if (!schema) return null
36
63
  return catalog.find(registeredSchema => schema.name === registeredSchema.name) || null
@@ -0,0 +1,20 @@
1
+ // Leaves room for the sticky elements sitting at the top of the viewport.
2
+ const SCROLL_MARGIN = 100
3
+
4
+ /**
5
+ * Bring the top of a block back into view after its content changed (paging,
6
+ * searching, filtering), so the reader restarts at the first result.
7
+ *
8
+ * Does nothing when the top of the block is already on screen: aligning the
9
+ * viewport on a block the reader is already looking at moves the page under
10
+ * them for no reason.
11
+ */
12
+ export function scrollToBlockTop(block: HTMLElement | null | undefined) {
13
+ if (!block) return
14
+
15
+ const { top } = block.getBoundingClientRect()
16
+ if (top >= 0 && top <= window.innerHeight) return
17
+
18
+ block.style.scrollMarginTop = `${SCROLL_MARGIN}px`
19
+ block.scrollIntoView({ behavior: 'smooth', block: 'start' })
20
+ }