Skip to content

Commit d9b7db6

Browse files
author
Bastien Guerry
committed
src/faq-server-dsfr.clj: Add script
1 parent 9031712 commit d9b7db6

File tree

1 file changed

+381
-0
lines changed

1 file changed

+381
-0
lines changed

src/faq-server-dsfr.clj

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
#!/usr/bin/env bb
2+
3+
;; Copyright (c) DINUM, Bastien Guerry
4+
;; SPDX-License-Identifier: EPL-2.0
5+
;; License-Filename: LICENSE.txt
6+
7+
(ns faq-server-dsfr
8+
(:require [org.httpkit.server :as server]
9+
[cheshire.core :as json]
10+
[clojure.string :as str]
11+
[babashka.cli :as cli]))
12+
13+
;; Define CLI specs
14+
(def cli-options
15+
{:port {:desc "Port number for server"
16+
:default 8080
17+
:coerce :int}
18+
:source {:desc "Path to FAQ JSON file"
19+
:default "faq.json"}
20+
:title {:desc "Website title"
21+
:default "FAQ Logiciels Libres"}
22+
:tagline {:desc "Website tagline"
23+
:default "Questions fréquentes sur les logiciels libres"}
24+
:footer {:desc "Footer text"
25+
:default "FAQ Logiciels Libres - Code.gouv.fr"}
26+
:help {:desc "Show help"
27+
:alias :h
28+
:coerce :boolean}})
29+
30+
;; Settings with defaults
31+
(def settings
32+
{:title "FAQ Logiciels Libres"
33+
:tagline "Questions fréquentes sur les logiciels libres"
34+
:footer "FAQ Logiciels Libres - Code.gouv.fr"
35+
:source "faq.json"
36+
:port 8080})
37+
38+
;; Load FAQ data directly
39+
(defn load-faq-data [source]
40+
(try
41+
(println "Loading FAQ data from" source)
42+
(let [content (slurp source)
43+
data (json/parse-string content true)]
44+
(println "Loaded" (count data) "FAQ items")
45+
data)
46+
(catch Exception e
47+
(println "Error loading FAQ data from" source ":" (.getMessage e))
48+
[])))
49+
50+
;; Fix broken links in content
51+
(defn fix-content-links [content]
52+
(-> content
53+
(str/replace #"https://<em>(.*?)</em>" "https://$1")
54+
(str/replace #"</em>/<em>" "/")
55+
(str/replace #"</em><em>" "")
56+
(str/replace #"</em>_" "_")
57+
(str/replace #"_</em>" "_")))
58+
59+
;; Simple search function
60+
(defn search-faq [query faq-data]
61+
(if (or (nil? query) (empty? query))
62+
[]
63+
(let [query-lower (str/lower-case query)]
64+
(filter #(str/includes? (str/lower-case (:title %)) query-lower) faq-data))))
65+
66+
;; Function to get categories from path
67+
(defn get-categories [faq-data]
68+
(let [paths (map :path faq-data)
69+
categories (distinct (map second paths))]
70+
(sort categories)))
71+
72+
;; Get FAQ items by category
73+
(defn get-faqs-by-category [category faq-data]
74+
(filter #(= (second (:path %)) category) faq-data))
75+
76+
;; DSFR HTML Templates
77+
(defn dsfr-page-layout [page-title content]
78+
(str "<!DOCTYPE html>
79+
<html lang=\"fr\" data-fr-theme>
80+
<head>
81+
<meta charset=\"utf-8\">
82+
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">
83+
<title>" page-title " - " (:title settings) "</title>
84+
85+
<!-- DSFR -->
86+
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@gouvfr/dsfr@1.9.3/dist/dsfr/dsfr.min.css\">
87+
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@gouvfr/dsfr@1.9.3/dist/utility/utility.min.css\">
88+
<link rel=\"apple-touch-icon\" href=\"https://cdn.jsdelivr.net/npm/@gouvfr/dsfr@1.9.3/dist/favicon/apple-touch-icon.png\">
89+
<link rel=\"icon\" href=\"https://cdn.jsdelivr.net/npm/@gouvfr/dsfr@1.9.3/dist/favicon/favicon.svg\" type=\"image/svg+xml\">
90+
<link rel=\"shortcut icon\" href=\"https://cdn.jsdelivr.net/npm/@gouvfr/dsfr@1.9.3/dist/favicon/favicon.ico\" type=\"image/x-icon\">
91+
<meta name=\"theme-color\" content=\"#000091\">
92+
</head>
93+
<body>
94+
<header role=\"banner\" class=\"fr-header\">
95+
<div class=\"fr-header__body\">
96+
<div class=\"fr-container\">
97+
<div class=\"fr-header__body-row\">
98+
<div class=\"fr-header__brand fr-enlarge-link\">
99+
<div class=\"fr-header__brand-top\">
100+
<div class=\"fr-header__logo\">
101+
<p class=\"fr-logo\">
102+
République<br>Française
103+
</p>
104+
</div>
105+
</div>
106+
<div class=\"fr-header__service\">
107+
<a href=\"/\" title=\"Accueil - " (:title settings) "\">
108+
<p class=\"fr-header__service-title\">" (:title settings) "</p>
109+
</a>
110+
<p class=\"fr-header__service-tagline\">" (:tagline settings) "</p>
111+
</div>
112+
</div>
113+
</div>
114+
</div>
115+
</div>
116+
</header>
117+
118+
<main role=\"main\" class=\"fr-container fr-py-8w\">
119+
" content "
120+
</main>
121+
122+
<footer class=\"fr-footer\" role=\"contentinfo\">
123+
<div class=\"fr-container\">
124+
<div class=\"fr-footer__body\">
125+
<div class=\"fr-footer__brand fr-enlarge-link\">
126+
<p class=\"fr-logo\">République<br>Française</p>
127+
</div>
128+
<div class=\"fr-footer__content\">
129+
<p class=\"fr-footer__content-desc\">" (:footer settings) "</p>
130+
<ul class=\"fr-footer__content-list\">
131+
<li class=\"fr-footer__content-item\">
132+
<a class=\"fr-footer__content-link\" href=\"https://info.gouv.fr\" target=\"_blank\" rel=\"noopener\">info.gouv.fr</a>
133+
</li>
134+
<li class=\"fr-footer__content-item\">
135+
<a class=\"fr-footer__content-link\" href=\"https://service-public.fr\" target=\"_blank\" rel=\"noopener\">service-public.fr</a>
136+
</li>
137+
<li class=\"fr-footer__content-item\">
138+
<a class=\"fr-footer__content-link\" href=\"https://legifrance.gouv.fr\" target=\"_blank\" rel=\"noopener\">legifrance.gouv.fr</a>
139+
</li>
140+
<li class=\"fr-footer__content-item\">
141+
<a class=\"fr-footer__content-link\" href=\"https://data.gouv.fr\" target=\"_blank\" rel=\"noopener\">data.gouv.fr</a>
142+
</li>
143+
</ul>
144+
</div>
145+
</div>
146+
<div class=\"fr-footer__bottom\">
147+
<div class=\"fr-footer__bottom-copy\">
148+
<p>© République Française, 2023-2025</p>
149+
</div>
150+
</div>
151+
</div>
152+
</footer>
153+
154+
<!-- DSFR Scripts -->
155+
<script type=\"module\" src=\"https://cdn.jsdelivr.net/npm/@gouvfr/dsfr@1.9.3/dist/dsfr/dsfr.module.min.js\"></script>
156+
<script type=\"text/javascript\" nomodule src=\"https://cdn.jsdelivr.net/npm/@gouvfr/dsfr@1.9.3/dist/dsfr/dsfr.nomodule.min.js\"></script>
157+
</body>
158+
</html>"))
159+
160+
(defn home-content [faq-data]
161+
(str "<div class=\"fr-grid-row fr-grid-row--center\">
162+
<div class=\"fr-col-12 fr-col-md-8\">
163+
<h1 class=\"fr-mt-8w\">" (:title settings) "</h1>
164+
<form action=\"/search\" method=\"get\" class=\"fr-search-bar fr-mt-4w fr-mb-8w\" role=\"search\">
165+
<label class=\"fr-label\" for=\"search-input\">Rechercher dans la FAQ</label>
166+
<input class=\"fr-input\" placeholder=\"Rechercher dans la FAQ...\" type=\"search\" id=\"search-input\" name=\"q\">
167+
<button class=\"fr-btn\" title=\"Rechercher\">
168+
Rechercher
169+
</button>
170+
</form>
171+
172+
<h2 class=\"fr-mt-8w\">Catégories</h2>
173+
<div class=\"fr-grid-row fr-grid-row--gutters\">"
174+
(str/join "\n"
175+
(for [category (get-categories faq-data)]
176+
(str "<div class=\"fr-col-12 fr-col-md-6 fr-mb-4w\">
177+
<div class=\"fr-card fr-enlarge-link fr-card--shadow\">
178+
<div class=\"fr-card__body\">
179+
<div class=\"fr-card__content\">
180+
<h3 class=\"fr-card__title\">
181+
<a href=\"/category?name=" (java.net.URLEncoder/encode category "UTF-8") "\" class=\"fr-card__link\">" category "</a>
182+
</h3>
183+
<p class=\"fr-card__desc\">" (count (get-faqs-by-category category faq-data)) " questions</p>
184+
</div>
185+
</div>
186+
</div>
187+
</div>")))
188+
"</div>
189+
</div>
190+
</div>"))
191+
192+
(defn category-content [category-name category-faqs]
193+
(str "<div class=\"fr-grid-row fr-grid-row--center\">
194+
<div class=\"fr-col-12 fr-col-md-8\">
195+
<div class=\"fr-mt-2w\">
196+
<a href=\"/\" class=\"fr-link fr-fi-arrow-left-line fr-link--icon-left\">Retour à l'accueil</a>
197+
</div>
198+
<h1 class=\"fr-mt-4w\">Catégorie : " category-name "</h1>
199+
200+
<div class=\"fr-accordions-group fr-mt-4w\">"
201+
(str/join "\n"
202+
(for [item category-faqs]
203+
(str "<section class=\"fr-accordion\">
204+
<h3 class=\"fr-accordion__title\">
205+
<button class=\"fr-accordion__btn\" aria-expanded=\"false\" aria-controls=\"accordion-"
206+
(hash (:title item)) "\">" (:title item) "</button>
207+
</h3>
208+
<div class=\"fr-collapse\" id=\"accordion-" (hash (:title item)) "\">
209+
" (fix-content-links (:content item)) "
210+
</div>
211+
</section>")))
212+
"</div>
213+
</div>
214+
</div>"))
215+
216+
(defn search-content [query results]
217+
(str "<div class=\"fr-grid-row fr-grid-row--center\">
218+
<div class=\"fr-col-12 fr-col-md-8\">
219+
<div class=\"fr-mt-2w\">
220+
<a href=\"/\" class=\"fr-link fr-fi-arrow-left-line fr-link--icon-left\">Retour à l'accueil</a>
221+
</div>
222+
<h1 class=\"fr-mt-4w\">Résultats de recherche</h1>
223+
<p class=\"fr-text\">Résultats pour \"" query "\" (" (count results) ") :</p>
224+
225+
<div class=\"fr-mt-4w\">"
226+
(if (empty? results)
227+
"<div class=\"fr-alert fr-alert--info\">
228+
<h3 class=\"fr-alert__title\">Aucun résultat</h3>
229+
<p>Aucun résultat ne correspond à votre recherche. Essayez avec d'autres termes.</p>
230+
</div>"
231+
(str "<div class=\"fr-accordions-group\">"
232+
(str/join "\n"
233+
(for [item results]
234+
(str "<section class=\"fr-accordion\">
235+
<h3 class=\"fr-accordion__title\">
236+
<button class=\"fr-accordion__btn\" aria-expanded=\"false\" aria-controls=\"accordion-"
237+
(hash (:title item)) "\">" (:title item) "</button>
238+
</h3>
239+
<div class=\"fr-collapse\" id=\"accordion-" (hash (:title item)) "\">
240+
" (fix-content-links (:content item)) "
241+
</div>
242+
</section>")))
243+
"</div>"))
244+
"</div>
245+
</div>
246+
</div>"))
247+
248+
(defn faq-content [item]
249+
(str "<div class=\"fr-grid-row fr-grid-row--center\">
250+
<div class=\"fr-col-12 fr-col-md-8\">
251+
<div class=\"fr-mt-2w\">
252+
<a href=\"javascript:history.back()\" class=\"fr-link fr-fi-arrow-left-line fr-link--icon-left\">Retour</a>
253+
</div>
254+
<article class=\"fr-mt-4w\">
255+
<h1>" (:title item) "</h1>
256+
<div class=\"fr-callout fr-mt-4w\">
257+
<div class=\"fr-callout__text\">
258+
" (fix-content-links (:content item)) "
259+
</div>
260+
</div>
261+
<p class=\"fr-text--xs fr-mt-4w\">
262+
Catégorie: <a href=\"/category?name=" (java.net.URLEncoder/encode (second (:path item)) "UTF-8") "\">" (second (:path item)) "</a>
263+
</p>
264+
</article>
265+
</div>
266+
</div>"))
267+
268+
(defn not-found-content []
269+
"<div class=\"fr-grid-row fr-grid-row--center\">
270+
<div class=\"fr-col-12 fr-col-md-8\">
271+
<div class=\"fr-mt-2w\">
272+
<a href=\"/\" class=\"fr-link fr-fi-arrow-left-line fr-link--icon-left\">Retour à l'accueil</a>
273+
</div>
274+
<h1 class=\"fr-mt-4w\">FAQ introuvable</h1>
275+
<div class=\"fr-alert fr-alert--error fr-mt-4w\">
276+
<h3 class=\"fr-alert__title\">L'article demandé n'existe pas</h3>
277+
<p>Vérifiez l'URL ou effectuez une nouvelle recherche.</p>
278+
</div>
279+
</div>
280+
</div>")
281+
282+
(defn error-content []
283+
"<div class=\"fr-grid-row fr-grid-row--center\">
284+
<div class=\"fr-col-12 fr-col-md-8\">
285+
<div class=\"fr-mt-2w\">
286+
<a href=\"/\" class=\"fr-link fr-fi-arrow-left-line fr-link--icon-left\">Retour à l'accueil</a>
287+
</div>
288+
<h1 class=\"fr-mt-4w\">Page non trouvée</h1>
289+
<div class=\"fr-alert fr-alert--error fr-mt-4w\">
290+
<h3 class=\"fr-alert__title\">La page demandée n'existe pas</h3>
291+
<p>Vérifiez l'URL ou retournez à l'accueil.</p>
292+
</div>
293+
</div>
294+
</div>")
295+
296+
;; Create app function with faq-data as parameter
297+
(defn create-app [faq-data]
298+
(fn [{:keys [request-method uri query-string]}]
299+
(let [params (when query-string
300+
(into {} (for [pair (str/split query-string #"&")]
301+
(let [[k v] (str/split pair #"=")]
302+
[(keyword k) (java.net.URLDecoder/decode v "UTF-8")]))))]
303+
304+
(case [request-method uri]
305+
[:get "/"]
306+
{:status 200
307+
:headers {"Content-Type" "text/html; charset=utf-8"}
308+
:body (dsfr-page-layout "Accueil" (home-content faq-data))}
309+
310+
[:get "/category"]
311+
(let [category-name (:name params)
312+
category-faqs (get-faqs-by-category category-name faq-data)]
313+
{:status 200
314+
:headers {"Content-Type" "text/html; charset=utf-8"}
315+
:body (dsfr-page-layout (str "Catégorie: " category-name)
316+
(category-content category-name category-faqs))})
317+
318+
[:get "/search"]
319+
(let [query (:q params)
320+
results (search-faq query faq-data)]
321+
{:status 200
322+
:headers {"Content-Type" "text/html; charset=utf-8"}
323+
:body (dsfr-page-layout (str "Résultats pour: " query)
324+
(search-content query results))})
325+
326+
[:get "/faq"]
327+
(let [id (:id params)
328+
item (first (filter #(= (:title %) id) faq-data))]
329+
(if item
330+
{:status 200
331+
:headers {"Content-Type" "text/html; charset=utf-8"}
332+
:body (dsfr-page-layout (:title item)
333+
(faq-content item))}
334+
{:status 404
335+
:headers {"Content-Type" "text/html; charset=utf-8"}
336+
:body (dsfr-page-layout "FAQ introuvable"
337+
(not-found-content))}))
338+
339+
;; Default route - 404
340+
{:status 404
341+
:headers {"Content-Type" "text/html; charset=utf-8"}
342+
:body (dsfr-page-layout "Page non trouvée"
343+
(error-content))}))))
344+
345+
;; Show help
346+
(defn show-help []
347+
(println "FAQ Web Server - DSFR")
348+
(println "Usage: faq-server-dsfr.clj [options]")
349+
(println (cli/format-opts {:spec cli-options}))
350+
(System/exit 0))
351+
352+
;; Main function
353+
(defn -main []
354+
(try
355+
;; Parse command line arguments with babashka.cli
356+
(let [opts (cli/parse-opts *command-line-args* {:spec cli-options})
357+
parsed-settings (merge settings opts)]
358+
359+
;; Show help if requested
360+
(when (:help parsed-settings)
361+
(show-help))
362+
363+
;; Update settings
364+
(alter-var-root #'settings (constantly parsed-settings))
365+
366+
;; Load FAQ data
367+
(let [faq-data (load-faq-data (:source parsed-settings))]
368+
;; Start the server
369+
(println "Starting server at http://localhost:" (:port settings))
370+
(println "Site title:" (:title settings))
371+
(println "Site tagline:" (:tagline settings))
372+
(server/run-server (create-app faq-data) {:port (:port settings)})
373+
(println "Server started. Press Ctrl+C to stop.")
374+
@(promise)))
375+
(catch Exception e
376+
(println "ERROR:" (.getMessage e))
377+
(.printStackTrace e)
378+
(System/exit 1))))
379+
380+
;; Start the server
381+
(-main)

0 commit comments

Comments
 (0)