Add UI for articles.

This commit is contained in:
Emmanuel 2022-08-10 17:50:13 +02:00
parent 7853ca4ee6
commit 8edab39363
26 changed files with 1282 additions and 79 deletions

View file

@ -4,4 +4,6 @@ DB_HOST="localhost"
DB_PORT=5432
DB_USER="legi"
# Change value of DB_PASSWORD!
DB_PASSWORD="legi"
DB_PASSWORD="legi"
TITLE="Tricoteuses Données légales (exemple)"

140
package-lock.json generated
View file

@ -10,6 +10,8 @@
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@auditors/core": "^0.3.0",
"@iconify-icons/codicon": "^1.2.15",
"@iconify/svelte": "^2.2.1",
"@playwright/test": "^1.22.2",
"@sveltejs/adapter-node": "^1.0.0-next.86",
"@sveltejs/kit": "next",
@ -18,6 +20,7 @@
"@types/he": "^1.1.2",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
"augmented-data-viewer": "^0.1.1",
"autoprefixer": "^10.4.7",
"daisyui": "^2.18.1",
"dotenv": "^16.0.1",
@ -118,6 +121,30 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true
},
"node_modules/@iconify-icons/codicon": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@iconify-icons/codicon/-/codicon-1.2.15.tgz",
"integrity": "sha512-CrRvt+0kKM6LVWNFErvWFOZEFwRPnvW2HLpIab148WNECXfNgVdTYhZRwtClNq59pEZ13cv0lg+cQm/ZDGlm+w==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/svelte": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@iconify/svelte/-/svelte-2.2.1.tgz",
"integrity": "sha512-eWZq8CRrr3WfnKAj8SWknfE3S/d+j/AzEcypeJaHurS1s4zTdFnkjATcFa8lerGtcX0PAtXiVL94tbIEd69N+w==",
"dev": true,
"funding": {
"url": "http://github.com/sponsors/cyberalien"
}
},
"node_modules/@iconify/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
"integrity": "sha512-Jh0llaK2LRXQoYsorIH8maClebsnzTcve+7U3rQUSnC11X4jtPnFuyatqFLvMxZ8MLG8dB4zfHsbPfuvxluONw==",
"dev": true
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@ -630,6 +657,16 @@
"node": ">=8"
}
},
"node_modules/augmented-data-viewer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/augmented-data-viewer/-/augmented-data-viewer-0.1.1.tgz",
"integrity": "sha512-Hn6MA2SOkiA028dhNtZnqozERvhPehWBaV8ebTtrB2wMozqfsUZlI+jNVKJKosMtyjduRi13Pv6RSzKFYPYp5Q==",
"dev": true,
"dependencies": {
"@iconify-icons/codicon": "^1.2.15",
"@iconify/svelte": "^2.2.1"
}
},
"node_modules/autoprefixer": {
"version": "10.4.8",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.8.tgz",
@ -756,9 +793,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001374",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001374.tgz",
"integrity": "sha512-mWvzatRx3w+j5wx/mpFN5v5twlPrabG8NqX2c6e45LCpymdoGqNvRkRutFUqpRTXKFQFNQJasvK0YT7suW6/Hw==",
"version": "1.0.30001375",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001375.tgz",
"integrity": "sha512-kWIMkNzLYxSvnjy0hL8w1NOaWNr2rn39RTAVyIwcw8juu60bZDWiF1/loOYANzjtJmy6qPgNmn38ro5Pygagdw==",
"dev": true,
"funding": [
{
@ -1029,9 +1066,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.212",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.212.tgz",
"integrity": "sha512-LjQUg1SpLj2GfyaPDVBUHdhmlDU1vDB4f0mJWSGkISoXQrn5/lH3ECPCuo2Bkvf6Y30wO+b69te+rZK/llZmjg==",
"version": "1.4.213",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.213.tgz",
"integrity": "sha512-+3DbGHGOCHTVB/Ms63bGqbyC1b8y7Fk86+7ltssB8NQrZtSCvZG6eooSl9U2Q0yw++fL2DpHKOdTU0NVEkFObg==",
"dev": true
},
"node_modules/es6-promise": {
@ -1990,15 +2027,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/import-fresh/node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@ -2781,6 +2809,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@ -3401,13 +3438,13 @@
"dev": true
},
"node_modules/vite": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.0.4.tgz",
"integrity": "sha512-NU304nqnBeOx2MkQnskBQxVsa0pRAH5FphokTGmyy8M3oxbvw7qAXts2GORxs+h/2vKsD+osMhZ7An6yK6F1dA==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.0.5.tgz",
"integrity": "sha512-bRvrt9Tw8EGW4jj64aYFTnVg134E8hgDxyl/eEHnxiGqYk7/pTPss6CWlurqPOUzqvEoZkZ58Ws+Iu8MB87iMA==",
"dev": true,
"dependencies": {
"esbuild": "^0.14.47",
"postcss": "^8.4.14",
"postcss": "^8.4.16",
"resolve": "^1.22.1",
"rollup": "^2.75.6"
},
@ -3562,6 +3599,27 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true
},
"@iconify-icons/codicon": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@iconify-icons/codicon/-/codicon-1.2.15.tgz",
"integrity": "sha512-CrRvt+0kKM6LVWNFErvWFOZEFwRPnvW2HLpIab148WNECXfNgVdTYhZRwtClNq59pEZ13cv0lg+cQm/ZDGlm+w==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify/svelte": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@iconify/svelte/-/svelte-2.2.1.tgz",
"integrity": "sha512-eWZq8CRrr3WfnKAj8SWknfE3S/d+j/AzEcypeJaHurS1s4zTdFnkjATcFa8lerGtcX0PAtXiVL94tbIEd69N+w==",
"dev": true
},
"@iconify/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
"integrity": "sha512-Jh0llaK2LRXQoYsorIH8maClebsnzTcve+7U3rQUSnC11X4jtPnFuyatqFLvMxZ8MLG8dB4zfHsbPfuvxluONw==",
"dev": true
},
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@ -3903,6 +3961,16 @@
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true
},
"augmented-data-viewer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/augmented-data-viewer/-/augmented-data-viewer-0.1.1.tgz",
"integrity": "sha512-Hn6MA2SOkiA028dhNtZnqozERvhPehWBaV8ebTtrB2wMozqfsUZlI+jNVKJKosMtyjduRi13Pv6RSzKFYPYp5Q==",
"dev": true,
"requires": {
"@iconify-icons/codicon": "^1.2.15",
"@iconify/svelte": "^2.2.1"
}
},
"autoprefixer": {
"version": "10.4.8",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.8.tgz",
@ -3979,9 +4047,9 @@
"dev": true
},
"caniuse-lite": {
"version": "1.0.30001374",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001374.tgz",
"integrity": "sha512-mWvzatRx3w+j5wx/mpFN5v5twlPrabG8NqX2c6e45LCpymdoGqNvRkRutFUqpRTXKFQFNQJasvK0YT7suW6/Hw==",
"version": "1.0.30001375",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001375.tgz",
"integrity": "sha512-kWIMkNzLYxSvnjy0hL8w1NOaWNr2rn39RTAVyIwcw8juu60bZDWiF1/loOYANzjtJmy6qPgNmn38ro5Pygagdw==",
"dev": true
},
"chalk": {
@ -4177,9 +4245,9 @@
"dev": true
},
"electron-to-chromium": {
"version": "1.4.212",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.212.tgz",
"integrity": "sha512-LjQUg1SpLj2GfyaPDVBUHdhmlDU1vDB4f0mJWSGkISoXQrn5/lH3ECPCuo2Bkvf6Y30wO+b69te+rZK/llZmjg==",
"version": "1.4.213",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.213.tgz",
"integrity": "sha512-+3DbGHGOCHTVB/Ms63bGqbyC1b8y7Fk86+7ltssB8NQrZtSCvZG6eooSl9U2Q0yw++fL2DpHKOdTU0NVEkFObg==",
"dev": true
},
"es6-promise": {
@ -4799,14 +4867,6 @@
"requires": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
},
"dependencies": {
"resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true
}
}
},
"imurmurhash": {
@ -5351,6 +5411,12 @@
"supports-preserve-symlinks-flag": "^1.0.0"
}
},
"resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true
},
"reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@ -5752,14 +5818,14 @@
"dev": true
},
"vite": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.0.4.tgz",
"integrity": "sha512-NU304nqnBeOx2MkQnskBQxVsa0pRAH5FphokTGmyy8M3oxbvw7qAXts2GORxs+h/2vKsD+osMhZ7An6yK6F1dA==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.0.5.tgz",
"integrity": "sha512-bRvrt9Tw8EGW4jj64aYFTnVg134E8hgDxyl/eEHnxiGqYk7/pTPss6CWlurqPOUzqvEoZkZ58Ws+Iu8MB87iMA==",
"dev": true,
"requires": {
"esbuild": "^0.14.47",
"fsevents": "~2.3.2",
"postcss": "^8.4.14",
"postcss": "^8.4.16",
"resolve": "^1.22.1",
"rollup": "^2.75.6"
}

View file

@ -23,6 +23,8 @@
},
"devDependencies": {
"@auditors/core": "^0.3.0",
"@iconify-icons/codicon": "^1.2.15",
"@iconify/svelte": "^2.2.1",
"@playwright/test": "^1.22.2",
"@sveltejs/adapter-node": "^1.0.0-next.86",
"@sveltejs/kit": "next",
@ -31,6 +33,7 @@
"@types/he": "^1.1.2",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
"augmented-data-viewer": "^0.1.1",
"autoprefixer": "^10.4.7",
"daisyui": "^2.18.1",
"dotenv": "^16.0.1",

4
src/app.d.ts vendored
View file

@ -6,6 +6,8 @@ declare namespace App {
// interface Platform {}
// interface PrivateEnv {}
// interface PublicEnv {}
// interface Session {}
interface Session {
title: string
}
// interface Stuff {}
}

7
src/hooks.ts Normal file
View file

@ -0,0 +1,7 @@
import config from "$lib/server/config"
export function getSession(): App.Session {
return {
title: config.title,
}
}

171
src/lib/auditors/queries.ts Normal file
View file

@ -0,0 +1,171 @@
import type { Audit, Auditor } from "@auditors/core"
import {
auditArray,
auditChain,
auditFunction,
auditInteger,
auditSetNullish,
auditStringToNumber,
auditTest,
auditTrimString,
} from "@auditors/core"
export function auditPaginationQuery(
audit: Audit,
query: URLSearchParams,
): [unknown, unknown] {
if (query == null) {
return [query, null]
}
if (!(query instanceof URLSearchParams)) {
return audit.unexpectedType(query, "URLSearchParams")
}
const data: { [key: string]: unknown } = {}
for (const [key, value] of query.entries()) {
let values = data[key] as string[] | undefined
if (values === undefined) {
values = data[key] = []
}
values.push(value)
}
const errors: { [key: string]: unknown } = {}
const remainingKeys = new Set(Object.keys(data))
audit.attribute(
data,
"limit",
true,
errors,
remainingKeys,
auditQuerySingleton(
auditTrimString,
auditStringToNumber,
auditInteger,
auditTest(
(value: number) => value >= 1,
"Value must be greater than or equal to 1",
),
auditTest(
(value: number) => value <= 100,
"Value must be less than or equal to 100",
),
auditSetNullish(10),
),
)
audit.attribute(
data,
"offset",
true,
errors,
remainingKeys,
auditQuerySingleton(
auditTrimString,
auditStringToNumber,
auditInteger,
auditTest(
(value: number) => value >= 0,
"Value must be greater than or equal to 0",
),
auditSetNullish(0),
),
)
// Keep the remaining keys as is.
remainingKeys.clear()
return audit.reduceRemaining(data, errors, remainingKeys, auditSetNullish({}))
}
export function auditQuerySingleton(...auditors: Auditor[]) {
return auditChain(
auditArray(),
auditTest(
(values) => values.length <= 1,
"Parameter must be present only once in query",
),
auditFunction((value) => value[0]),
...auditors,
)
}
export function auditSearchQuery(
audit: Audit,
query: URLSearchParams,
): [unknown, unknown] {
if (query == null) {
return [query, null]
}
if (!(query instanceof URLSearchParams)) {
return audit.unexpectedType(query, "URLSearchParams")
}
const data: { [key: string]: unknown } = {}
for (const [key, value] of query.entries()) {
let values = data[key] as string[] | undefined
if (values === undefined) {
values = data[key] = []
}
values.push(value)
}
const errors: { [key: string]: unknown } = {}
const remainingKeys = new Set(Object.keys(data))
auditSearchQueryContent(audit, data, errors, remainingKeys)
return audit.reduceRemaining(data, errors, remainingKeys, auditSetNullish({}))
}
export function auditSearchQueryContent(
audit: Audit,
data: { [key: string]: unknown },
errors: { [key: string]: unknown },
remainingKeys: Set<string>,
) {
audit.attribute(
data,
"limit",
true,
errors,
remainingKeys,
auditQuerySingleton(
auditTrimString,
auditStringToNumber,
auditInteger,
auditTest(
(value: number) => value >= 1,
"Value must be greater than or equal to 1",
),
auditTest(
(value: number) => value <= 1000,
"Value must be less than or equal to 1000",
),
auditSetNullish(20),
),
)
audit.attribute(
data,
"offset",
true,
errors,
remainingKeys,
auditQuerySingleton(
auditTrimString,
auditStringToNumber,
auditInteger,
auditTest(
(value: number) => value >= 0,
"Value must be greater than or equal to 0",
),
auditSetNullish(0),
),
)
audit.attribute(
data,
"q",
true,
errors,
remainingKeys,
auditQuerySingleton(auditTrimString),
)
}

View file

@ -0,0 +1,125 @@
<script lang="ts">
import { cleanAudit } from "@auditors/core"
import { page } from "$app/stores"
import { auditPaginationQuery } from "$lib/auditors/queries"
import type { PaginationQuery } from "$lib/queries"
export let count: number | undefined | null = undefined // Count is not always known.
export let currentPageCount: number
$: url = $page.url
$: query = ensureValidQuery(url.searchParams)
$: limit = query.limit
$: offset = query.offset
$: pathname = url.pathname
function ensureValidQuery(query: URLSearchParams): PaginationQuery {
const [validQuery, queryError] = auditPaginationQuery(
cleanAudit,
query,
) as [PaginationQuery, unknown]
if (queryError !== null) {
console.warn(
`Query error at ${pathname}: ${JSON.stringify(
queryError,
null,
2,
)}\n\n${JSON.stringify(validQuery, null, 2)}`,
)
return { limit: 20, offset: 0 }
}
return validQuery
}
function newPaginationUrl(
pathname: string,
query: Partial<PaginationQuery> | { [key: string]: string },
{ limit, offset }: { limit?: number; offset?: number } = {},
) {
query = { ...query }
if (limit != null && limit !== 20) {
query.limit = limit
} else {
delete query.limit
}
if (offset) {
query.offset = offset
} else {
delete query.offset
}
const search = new URLSearchParams(
Object.entries(query).map(([key, value]) => [
key,
typeof value === "number" ? value.toString() : value,
]),
).toString()
return search ? `${pathname}?${search}` : pathname
}
function pageNumber(limit: number, offset: number): number {
return Math.floor(offset / limit) + 1
}
</script>
<div class="my-4 flex items-baseline justify-evenly">
<p class="mr-4">
{#if currentPageCount === 0}
Aucun résultat
{:else}
Résultats {offset + 1} à {offset + currentPageCount}{#if count != null}
sur {count}{/if}
{/if}
</p>
<div class="btn-group">
{#if offset > 0}
<a
class="btn btn-outline"
href={newPaginationUrl(pathname, query, {
offset: Math.max(offset - limit, 0),
})}
>
Précédent
</a>
{:else}
<span class="btn btn-disabled">Précédent</span>
{/if}
{#each [offset - 3 * limit, offset - 2 * limit, offset - limit] as previousOffset (previousOffset)}
{#if previousOffset >= 0 && (count == null || previousOffset < count)}
<a
class="btn btn-outline"
href={newPaginationUrl(pathname, query, { offset: previousOffset })}
>
{pageNumber(limit, previousOffset)}
</a>
{/if}
{/each}
<span class="btn btn-disabled">
{pageNumber(limit, offset)}
<span class="sr-only">(page actuelle)</span>
</span>
{#each [offset + limit, offset + 2 * limit, offset + 3 * limit] as nextOffset (nextOffset)}
{#if nextOffset >= 0 && (count == null || nextOffset < count)}
<a
class="btn btn-outline"
href={newPaginationUrl(pathname, query, { offset: nextOffset })}
>
{pageNumber(limit, nextOffset)}
</a>
{/if}
{/each}
{#if count == null || offset + limit < count}
<a
class="btn btn-outline"
href={newPaginationUrl(pathname, query, { offset: offset + limit })}
>Suivant</a
>
{:else}
<span class="btn btn-disabled">Suivant</span>
{/if}
</div>
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import Icon from "@iconify/svelte"
import errorIcon from "@iconify-icons/codicon/error"
import { realErrorFromError } from "./errors"
import ErrorValue from "./ErrorValue.svelte"
export let error: unknown
export let hideError = false
$: realError = realErrorFromError(error)
</script>
{#if realError !== error || (!hideError && realError != null)}
<div class="alert alert-error">
<div class="flex-1">
<Icon class="mr-1" icon={errorIcon} />
<ErrorValue bind:error frame={false} {hideError} />
</div>
</div>
{/if}

View file

@ -0,0 +1,61 @@
<script lang="ts">
import type { AuditSwitchError } from "@auditors/core"
import { realErrorFromError } from "./errors"
export let error: unknown
export let frame = true
export let hideError = false
$: realError = realErrorFromError(error)
function asAuditSwitchError(value: unknown) {
return value as AuditSwitchError
}
function asObject(value: unknown) {
return value as { [key: string]: unknown }
}
function changeSwitchIndex({ target }: Event) {
const { value } = target as HTMLSelectElement
error = {
...asAuditSwitchError(error),
index: parseInt(value),
}
}
</script>
{#if realError !== error}
{@const switchError = asAuditSwitchError(error)}
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text">Alternative Errors:</span>
</label>
<select
class="select select-bordered w-full max-w-xs"
on:blur={changeSwitchIndex}
on:change={changeSwitchIndex}
value={switchError.index}
>
{#each switchError.errors as _, index}
<option value={index}>Alternative {index + 1}</option>
{/each}
</select>
</div>
{/if}
{#if !hideError}
{#if realError !== null && typeof realError === "object"}
<ul class={frame ? "list-inside list-disc pl-4" : null}>
{#each Object.entries(asObject(error)) as [itemKey, itemError]}
<li>
<span>{itemKey}:</span>
<svelte:self error={itemError} />
</li>
{/each}
</ul>
{:else if realError != null}
<span>{realError}</span>
{/if}
{/if}

View file

@ -0,0 +1,13 @@
import type { AuditSwitchError } from "@auditors/core"
export function realErrorFromError(error: unknown): unknown {
if (
error !== null &&
typeof error === "object" &&
(error as { [key: string]: unknown })["@auditors/error"] === "switch"
) {
const { errors, index } = error as AuditSwitchError
return realErrorFromError(errors[index])
}
return error
}

128
src/lib/data.ts Normal file
View file

@ -0,0 +1,128 @@
export interface Article {
META: {
META_COMMUN: MetaCommun
META_SPEC: {
META_ARTICLE: {
NUM: string
ETAT: Etat
TYPE: "AUTONOME"
DATE_FIN: string
DATE_DEBUT: string
}
}
}
CONTEXTE: {
TEXTE: {
TITRE_TXT: TitreTexte | TitreTexte[]
}
}
VERSIONS: {
VERSION: Array<{
"@etat": Etat
LIEN_ART: {
"@id": string
"@fin": string
"@num": string
"@etat": Etat
"@debut": string
"@origine": "LEGI"
}
}>
}
BLOC_TEXTUEL: {
CONTENU: string // HTML
}
}
export type EliId = string
export interface EliVersions {}
export type Etat = "MODIFIE" | "VIGUEUR"
export type LegiObject =
| Article
| EliId
| EliVersions
| Section
| Struct
| Version
export type LegiObjectType =
| "article"
| "eli_id"
| "eli_versions"
| "sections"
| "struct"
| "version"
export interface MetaCommun {
ID: string
}
export interface Section {
ID: string
}
export interface Struct {
META: {
META_COMMUN: MetaCommun
}
}
export interface TitreTexte {
"#text": string
}
export interface Version {
META: {
META_COMMUN: MetaCommun
}
}
export function assertNeverLegiObjectType(type: never): never {
throw `Unexpected type for LEGI object: ${type}`
}
export function pathnameFromLegiObject(
type: LegiObjectType,
object: LegiObject,
): string {
switch (type) {
case "article":
return `/articles/${(object as Article).META.META_COMMUN.ID}`
case "eli_id":
return `/eli/ids/TODO`
case "eli_versions":
return `/eli/ids/TODO`
case "sections":
return `/sections/${(object as Section).ID}`
case "struct":
return `/structs/${(object as Struct).META.META_COMMUN.ID}`
case "version":
return `/versions/${(object as Version).META.META_COMMUN.ID}`
default:
assertNeverLegiObjectType(type)
}
}
export function pathnameFromLegiObjectId(
type: LegiObjectType,
id: string,
): string {
switch (type) {
case "article":
return `/articles/${id}`
case "eli_id":
return `/eli/ids/TODO`
case "eli_versions":
return `/eli/ids/TODO`
case "sections":
return `/sections/${id}`
case "struct":
return `/structs/${id}`
case "version":
return `/versions/${id}`
default:
assertNeverLegiObjectType(type)
}
}

10
src/lib/queries.ts Normal file
View file

@ -0,0 +1,10 @@
export interface PaginationQuery {
limit: number
offset: number
}
export interface SearchQuery {
limit: number
offset: number
q?: string
}

View file

@ -6,42 +6,17 @@ import path from "path"
import type { JSONValue } from "postgres"
import sade from "sade"
import type {
Article,
EliId,
EliVersions,
Section,
Struct,
Version,
} from "$lib/data"
import { db } from "$lib/server/database"
import { walkDir } from "$lib/server/file_systems"
interface Article {
META: {
META_COMMUN: MetaCommun
}
BLOC_TEXTUEL: {
CONTENU: string // HTML
}
}
type EliId = string
interface EliVersions {}
interface MetaCommun {
ID: string
}
interface Section {
ID: string
}
interface Struct {
META: {
META_COMMUN: MetaCommun
}
}
interface Version {
META: {
META_COMMUN: MetaCommun
}
}
interface XmlHeader {
"@encoding": "UTF-8"
"@version": "1.0"

View file

@ -32,6 +32,15 @@ export function auditConfig(
auditDb,
auditRequire,
)
audit.attribute(
data,
"title",
true,
errors,
remainingKeys,
auditTrimString,
auditRequire,
)
return audit.reduceRemaining(data, errors, remainingKeys)
}

View file

@ -10,6 +10,7 @@ export interface Config {
user: string
password: string
}
title: string
}
const config = {
@ -20,6 +21,7 @@ const config = {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
},
title: process.env.TITLE,
}
const [validConfig, error] = validateConfig(config) as [Config, unknown]

150
src/lib/summaries.ts Normal file
View file

@ -0,0 +1,150 @@
import type { Access, Summarizer, Summary } from "augmented-data-viewer"
import {
type Article,
assertNeverLegiObjectType,
type LegiObject,
type LegiObjectType,
pathnameFromLegiObject,
pathnameFromLegiObjectId,
type Section,
type Struct,
type Version,
} from "$lib/data"
export const summarizeArticleProperties: Summarizer = (access, value) => {
if (access?.key === "article") {
return summarizeLegiObject(access, "article", value)
}
if (access?.access?.key === "articles") {
return summarizeLegiObjectToLink(access, "article", value)
}
if (access?.key === "@id" && access?.access?.key === "LIEN_ART") {
return {
content: value as string,
href: pathnameFromLegiObjectId("article", value as string),
type: "link",
}
}
if (access?.key === "CONTENU") {
return { content: value as string, type: "html" }
}
if (access?.access?.key === "VERSION") {
const version = value as Article["VERSIONS"]["VERSION"][0]
const lienArt = version.LIEN_ART
return {
content: {
items: [
`Article ${lienArt["@num"]} en vigueur`,
...(lienArt["@fin"] === "2999-01-01"
? [
" depuis le ",
{
value: lienArt["@debut"],
type: "date",
},
]
: [
" du ",
{
value: lienArt["@debut"],
type: "date",
},
" au ",
{
value: lienArt["@fin"],
type: "date",
},
]),
],
type: "concatenation",
},
href: pathnameFromLegiObjectId("article", lienArt["@id"]),
type: "link",
}
}
return undefined
}
export function summarizeLegiObject(
access: Access | undefined,
type: LegiObjectType,
value: unknown,
): Summary | undefined {
switch (type) {
case "article": {
const article = value as Article | undefined
if (article === undefined) {
return undefined
}
const metaArticle = article.META.META_SPEC.META_ARTICLE
const titreTxt = article.CONTEXTE.TEXTE.TITRE_TXT
const titreTexte = (Array.isArray(titreTxt) ? titreTxt[0] : titreTxt)[
"#text"
]
return {
items: [
`Article ${metaArticle.TYPE} ${metaArticle.ETAT}${metaArticle.NUM}, en vigueur`,
...(metaArticle.DATE_FIN === "2999-01-01"
? [
" depuis le ",
{
value: metaArticle.DATE_DEBUT,
type: "date",
},
]
: [
" du ",
{
value: metaArticle.DATE_DEBUT,
type: "date",
},
" au ",
{
value: metaArticle.DATE_FIN,
type: "date",
},
]),
" (",
titreTexte,
")",
],
type: "concatenation",
}
}
case "eli_id":
return `/eli/ids/TODO`
case "eli_versions":
return `/eli/ids/TODO`
case "sections": {
const section = value as Section | undefined
return section?.ID
}
case "struct": {
const struct = value as Struct | undefined
return struct?.META.META_COMMUN.ID
}
case "version": {
const version = value as Version | undefined
return version?.META.META_COMMUN.ID
}
default:
assertNeverLegiObjectType(type)
}
}
export function summarizeLegiObjectToLink(
access: Access | undefined,
type: LegiObjectType,
value: unknown,
): Summary | undefined {
const objectSummary = summarizeLegiObject(access, type, value)
return objectSummary === undefined
? undefined
: {
content: objectSummary,
href: pathnameFromLegiObject(type, value as LegiObject),
type: "link",
}
}

View file

@ -1,5 +1,107 @@
<script>
<script lang="ts">
import "../app.css"
import Icon from "@iconify/svelte"
import chevronDown from "@iconify-icons/codicon/chevron-down"
import chevronRight from "@iconify-icons/codicon/chevron-right"
import threeBars from "@iconify-icons/codicon/three-bars"
import { session } from "$app/stores"
type MenuItem = MenuItemLink | MenuItemSubMenu
interface MenuItemBase {
href?: string
items?: MenuItemLink[]
label: string
}
interface MenuItemLink extends MenuItemBase {
href: string
}
interface MenuItemSubMenu extends MenuItemBase {
items: MenuItemLink[]
}
const menuItems: MenuItem[] = [
{ href: "/recherche", label: "Recherche" },
{
label: "Données",
items: [
{ href: "/articles", label: "Articles" },
{ href: "/eli/ids", label: "ELI ID" },
{ href: "/eli/versions", label: "ELI versions" },
{ href: "/sections", label: "Sections" },
{ href: "/structs", label: "Structs" },
{ href: "/versions", label: "Versions" },
],
},
]
const title = $session.title
</script>
<slot />
<div class="navbar mb-2 bg-neutral text-neutral-content">
<div class="navbar-start">
<div class="dropdown">
<button tabindex="0" class="btn btn-ghost lg:hidden">
<Icon class="h-5 w-5" icon={threeBars} />
</button>
<ul
tabindex="0"
class="dropdown-content menu rounded-box menu-compact mt-3 w-52 bg-neutral p-2 text-neutral-content shadow"
>
{#each menuItems as { href, items, label }}
{#if href !== undefined}
<li><a {href}>{label}</a></li>
{:else if items !== undefined}
<li tabindex="0">
<span class="justify-between">
{label}
<Icon class="h-5 w-5" icon={chevronRight} />
</span>
<ul class="p-2 bg-neutral text-neutral-content">
{#each items as { href, label }}
<li><a {href}>{label}</a></li>
{/each}
</ul>
</li>
{/if}
{/each}
</ul>
</div>
<a class="btn btn-ghost text-xl normal-case" href="/"> {title}</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal p-0">
{#each menuItems as { href, items, label }}
{#if href !== undefined}
<li><a {href}>{label}</a></li>
{:else if items !== undefined}
<li tabindex="0">
<span class="justify-between">
{label}
<Icon class="h-5 w-5" icon={chevronDown} />
</span>
<ul class="p-2 bg-neutral text-neutral-content">
{#each items as { href, label }}
<li><a {href}>{label}</a></li>
{/each}
</ul>
</li>
{/if}
{/each}
</ul>
</div>
<div class="navbar-end">
<a
class="btn"
href="https://git.en-root.org/tricoteuses/tricoteuses-legal-explorer"
>Contribuer</a
>
</div>
</div>
<div class="mx-auto w-11/12">
<slot />
</div>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { TreeView, SummaryView } from "augmented-data-viewer"
import ErrorAlert from "$lib/components/errors/ErrorAlert.svelte"
import type { Article } from "$lib/data"
import {
summarizeArticleProperties,
summarizeLegiObject,
} from "$lib/summaries"
export let error: unknown
export let article: Article
const summary = summarizeLegiObject({ key: "article" }, "article", article)
</script>
<header class="prose my-6 max-w-full">
<h2>Article</h2>
{#if summary !== undefined}
<h1>
<SummaryView {summary} />
</h1>
{/if}
</header>
{#if error != null}
<ErrorAlert {error} />
{/if}
{#if error == null}
<TreeView
access={{ key: "article" }}
frame={false}
open
summarize={summarizeArticleProperties}
value={article}
/>
{/if}

View file

@ -0,0 +1,19 @@
import type { RequestHandler } from "@sveltejs/kit"
import type { JSONObject } from "@sveltejs/kit/types/private"
import type { Article } from "$lib/data"
import { db } from "$lib/server/database"
export const GET: RequestHandler = async ({ params, url }) => {
const article = (
await db<{ data: Article }[]>`
SELECT data FROM articles
WHERE id = ${params.id}
`
).map(({ data }) => data)[0]
if (article === undefined) {
return { status: 404 }
}
return { body: { article: article as unknown as JSONObject } }
}

View file

@ -0,0 +1,66 @@
<script lang="ts">
// import Icon from "@iconify/svelte"
// import searchIcon from "@iconify-icons/codicon/search"
import { TreeView } from "augmented-data-viewer"
// import { page } from "$app/stores"
import ErrorAlert from "$lib/components/errors/ErrorAlert.svelte"
import Pagination from "$lib/components/Pagination.svelte"
import { summarizeArticleProperties } from "$lib/summaries"
export let error: unknown
export let articles: unknown[]
</script>
<header class="prose my-6 max-w-full">
<h1>Articles</h1>
</header>
<!-- <form action={$page.url.pathname} class="mx-auto max-w-sm" method="get">
<div class="form-control">
<div class="input-group">
<input
class="input input-bordered"
name="q"
type="search"
bind:value={q}
/>
<button class="btn btn-square" type="submit">
<Icon icon={searchIcon} />
</button>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Législature</span>
<select
class="select select-bordered"
name="legislature"
bind:value={legislature}
>
<option value="">Toutes</option>
{#each Object.entries(Legislature) as [key, value]}
{#if value !== "*"}
<option {value}>{key}</option>
{/if}
{/each}
</select>
</label>
</div>
</form> -->
{#if error != null}
<ErrorAlert {error} />
{/if}
{#if error == null}
<TreeView
access={{ key: "articles" }}
frame={false}
open
summarize={summarizeArticleProperties}
value={articles}
/>
<Pagination currentPageCount={articles.length ?? 0} />
{/if}

View file

@ -0,0 +1,68 @@
import { type Audit, auditSetNullish, cleanAudit } from "@auditors/core"
import type { RequestHandler } from "@sveltejs/kit"
import type { JSONObject } from "@sveltejs/kit/types/private"
import { auditSearchQueryContent } from "$lib/auditors/queries"
import type { Article } from "$lib/data"
import { db } from "$lib/server/database"
export function auditQuery(
audit: Audit,
query: URLSearchParams,
): [unknown, unknown] {
if (query == null) {
return [query, null]
}
if (!(query instanceof URLSearchParams)) {
return audit.unexpectedType(query, "URLSearchParams")
}
const data: { [key: string]: unknown } = {}
for (const [key, value] of query.entries()) {
let values = data[key] as string[] | undefined
if (values === undefined) {
values = data[key] = []
}
values.push(value)
}
const errors: { [key: string]: unknown } = {}
const remainingKeys = new Set(Object.keys(data))
auditSearchQueryContent(audit, data, errors, remainingKeys)
return audit.reduceRemaining(data, errors, remainingKeys, auditSetNullish({}))
}
export const GET: RequestHandler = async ({ url }) => {
const [query, queryError] = auditQuery(cleanAudit, url.searchParams) as [
{ limit: number; offset: number; q?: string },
unknown,
]
if (queryError !== null) {
console.error(
`Error in ${url.pathname} query:\n${JSON.stringify(
query,
null,
2,
)}\n\nError:\n${JSON.stringify(queryError, null, 2)}`,
)
return {
// status: 400,
body: {
error: {
query: queryError as unknown as JSONObject,
},
},
}
}
const { limit, offset } = query
const articles = (
await db<{ data: Article }[]>`
SELECT data FROM articles
OFFSET ${offset}
LIMIT ${limit}
`
).map(({ data }) => data)
return { body: { articles: articles as unknown as JSONObject[] } }
}

View file

@ -1,4 +1,33 @@
<h1>Welcome to SvelteKit</h1>
<p>
Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation
</p>
<script lang="ts">
import { session } from "$app/stores"
const title = $session.title
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
<main>
<div class="hero bg-base-200">
<div class="hero-content flex-col lg:flex-row">
<img
alt="Les Tricoteuses jacobines ou de Robespierre"
class="max-w-sm rounded-lg shadow-2xl"
src="/tricoteuses_jacobines_1301x1907.jpg"
/>
<div>
<h1 class="text-5xl font-bold">Tricoteuses</h1>
<p class="py-6">
Un site et des API pour permettre aux développeurs d'utiliser plus
facilement les données publiques légales de la <abbr
title="Direction de l'information légale et administrative"
>DILA</abbr
>
diffusées sur <a href="https://www.legifrance.gouv.fr/">Légifrance</a>
</p>
<a class="btn btn-primary" href="/recherche">Commencer</a>
</div>
</div>
</div>
</main>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import Icon from "@iconify/svelte"
import searchIcon from "@iconify-icons/codicon/search"
import { TreeView } from "augmented-data-viewer"
import { page } from "$app/stores"
import ErrorAlert from "$lib/components/errors/ErrorAlert.svelte"
import Pagination from "$lib/components/Pagination.svelte"
import { summarizeArticleProperties } from "$lib/summaries"
export let articles: unknown[]
export let error: unknown
export let q: string | undefined
</script>
<header class="prose my-6 max-w-full">
<h1>Recherche</h1>
</header>
<form action={$page.url.pathname} class="mx-auto max-w-sm" method="get">
<div class="form-control">
<div class="input-group">
<input
class="input input-bordered"
name="q"
type="search"
bind:value={q}
/>
<button class="btn btn-square" type="submit">
<Icon icon={searchIcon} />
</button>
</div>
</div>
</form>
{#if error != null}
<ErrorAlert {error} />
{/if}
{#if error == null && q !== undefined}
{#if articles === undefined}
<p>Aucun article trouvé</p>
{:else}
<TreeView
access={{ key: "articles" }}
frame={false}
open
summarize={summarizeArticleProperties}
value={articles}
/>
<Pagination currentPageCount={articles.length ?? 0} />
{/if}
{/if}

View file

@ -0,0 +1,79 @@
import { type Audit, auditSetNullish, cleanAudit } from "@auditors/core"
import type { RequestHandler } from "@sveltejs/kit"
import type { JSONObject } from "@sveltejs/kit/types/private"
import { auditSearchQueryContent } from "$lib/auditors/queries"
import type { Article } from "$lib/data"
import { db } from "$lib/server/database"
export function auditQuery(
audit: Audit,
query: URLSearchParams,
): [unknown, unknown] {
if (query == null) {
return [query, null]
}
if (!(query instanceof URLSearchParams)) {
return audit.unexpectedType(query, "URLSearchParams")
}
const data: { [key: string]: unknown } = {}
for (const [key, value] of query.entries()) {
let values = data[key] as string[] | undefined
if (values === undefined) {
values = data[key] = []
}
values.push(value)
}
const errors: { [key: string]: unknown } = {}
const remainingKeys = new Set(Object.keys(data))
auditSearchQueryContent(audit, data, errors, remainingKeys)
return audit.reduceRemaining(data, errors, remainingKeys, auditSetNullish({}))
}
export const GET: RequestHandler = async ({ url }) => {
const [query, queryError] = auditQuery(cleanAudit, url.searchParams) as [
{ limit: number; offset: number; q?: string },
unknown,
]
if (queryError !== null) {
console.error(
`Error in ${url.pathname} query:\n${JSON.stringify(
query,
null,
2,
)}\n\nError:\n${JSON.stringify(queryError, null, 2)}`,
)
return {
// status: 400,
body: {
error: {
query: queryError as unknown as JSONObject,
},
},
}
}
const { limit, offset, q } = query
if (q !== undefined) {
// https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000006308296/
// https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000006308296/1983-12-30/
// https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000006308296/1984-12-30/
// https://www.legifrance.gouv.fr/loda/article_lc/LEGIARTI000036456533
// https://www.legifrance.gouv.fr/loda/article_lc/LEGIARTI000036456533/2018-01-01
// https://www.legifrance.gouv.fr/loda/id/LEGIARTI000006317314/1983-12-30
const articleId = q.match(/LEGIARTI\d+/)?.[0]
if (articleId != null) {
const articles = (
await db<{ data: Article }[]>`
SELECT data FROM articles
WHERE id = ${articleId}
`
).map(({ data }) => data)
return { body: { articles: articles as unknown as JSONObject[], q } }
}
}
return { body: {} }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,009 KiB

View file

@ -2,7 +2,10 @@ const daisyui = require("daisyui")
const typography = require("@tailwindcss/typography")
const config = {
content: ["./src/**/*.{html,js,svelte,ts}"],
content: [
"./src/**/*.{html,js,svelte,ts}",
"./node_modules/augmented-data-viewer/**/*.{html,js,svelte,ts}",
],
theme: {
extend: {},