diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml index 27c9535..61b8c7f 100644 --- a/.github/workflows/test-deploy.yml +++ b/.github/workflows/test-deploy.yml @@ -22,6 +22,8 @@ jobs: - name: Install dependencies run: npm ci + - name: Guardia manifest percorsi (append-only) + run: npm run curriculum:check - name: Lint run: npm run lint - name: Test build website diff --git a/curriculum/manifest.json b/curriculum/manifest.json new file mode 100644 index 0000000..a018fdb --- /dev/null +++ b/curriculum/manifest.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "currentEpoch": 1, + "epochs": { + "1": [ + "programmatore/intro", + "programmatore/variabili", + "artefice/intro", + "artefice/perche-gli-oggetti", + "artefice/classi-e-istanze", + "artefice/metodi-di-classe-e-statici", + "artefice/mostrare-un-oggetto", + "artefice/incapsulamento", + "archivista/intro", + "apprendista/intro", + "apprendista/programmatore/le-basi/le-variabili", + "apprendista/programmatore/le-basi/i-tipi-di-dato/esercizi-rapidi", + "apprendista/programmatore/le-basi/i-tipi-di-dato/esercizio-convertitore", + "apprendista/programmatore/le-basi/i-tipi-di-dato/laboratorio-convertitore" + ] + } +} diff --git a/curriculum/presets.js b/curriculum/presets.js new file mode 100644 index 0000000..f135812 --- /dev/null +++ b/curriculum/presets.js @@ -0,0 +1,70 @@ +// Configurazioni predefinite (preset) del configuratore percorsi: i vecchi +// percorsi IT/Liceo/ITS diventano codici curati. `keys` elenca le lezioni +// INCLUSE come chiavi globali «/» — devono tutte esistere +// nell'epoca corrente del manifest (validato dal plugin curriculum). +// Le label sono migrate da src/contexts/pathLabels.ts. +module.exports = [ + { + id: 'it', + label: 'Istituto Tecnico', + short: 'IT', + keys: [ + 'programmatore/intro', + 'programmatore/variabili', + 'artefice/intro', + 'artefice/perche-gli-oggetti', + 'artefice/classi-e-istanze', + 'artefice/metodi-di-classe-e-statici', + 'artefice/mostrare-un-oggetto', + 'artefice/incapsulamento', + 'archivista/intro', + 'apprendista/intro', + 'apprendista/programmatore/le-basi/le-variabili', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/esercizi-rapidi', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/esercizio-convertitore', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/laboratorio-convertitore', + ], + }, + { + id: 'liceo', + label: 'Liceo', + short: 'Liceo', + keys: [ + 'programmatore/intro', + 'programmatore/variabili', + 'artefice/intro', + 'artefice/perche-gli-oggetti', + 'artefice/classi-e-istanze', + 'artefice/metodi-di-classe-e-statici', + 'artefice/mostrare-un-oggetto', + 'artefice/incapsulamento', + 'archivista/intro', + 'apprendista/intro', + 'apprendista/programmatore/le-basi/le-variabili', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/esercizi-rapidi', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/esercizio-convertitore', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/laboratorio-convertitore', + ], + }, + { + id: 'its', + label: 'ITS', + short: 'ITS', + keys: [ + // Il percorso ITS del vecchio sistema escludeva `variabili` dal vol. 1. + 'programmatore/intro', + 'artefice/intro', + 'artefice/perche-gli-oggetti', + 'artefice/classi-e-istanze', + 'artefice/metodi-di-classe-e-statici', + 'artefice/mostrare-un-oggetto', + 'artefice/incapsulamento', + 'archivista/intro', + 'apprendista/intro', + 'apprendista/programmatore/le-basi/le-variabili', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/esercizi-rapidi', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/esercizio-convertitore', + 'apprendista/programmatore/le-basi/i-tipi-di-dato/laboratorio-convertitore', + ], + }, +]; diff --git a/curriculum/toc/apprendista.js b/curriculum/toc/apprendista.js new file mode 100644 index 0000000..925b604 --- /dev/null +++ b/curriculum/toc/apprendista.js @@ -0,0 +1,46 @@ +// Albero sidebar UNICO della Biblioteca dell'Apprendista (Volume 4 — +// esercizi e laboratori). Vedi programmatore.js per il ruolo di questi file. +// +// Gli esercizi sono raggruppati per volume di provenienza: +// volume-sorgente > capitolo > lezione +// +// Modello IBRIDO al livello "lezione": +// - lezione con SOLO esercizi rapidi → è una PAGINA singola (un doc) +// - lezione con anche esercizi dedicati / laboratori → è una CATEGORIA, con +// sotto una pagina per tipo (Esercizi rapidi / Esercizio: … / Laboratorio: …) +// Gli esercizietti di una pagina-batch sono sezioni

(vanno nel TOC della +// pagina), non voci di sidebar. +// +// Regola di collapse (per evitare la "lista infinita"): +// - categorie volume-sorgente: collapsed: false → aperte, mostrano i capitoli +// - categorie capitolo / lezione: collapsed: true → chiuse di default +module.exports = [ + 'intro', + { + type: 'category', + label: 'Manuale del Programmatore', + collapsed: false, // volume-sorgente: aperto + items: [ + { + type: 'category', + label: 'Le basi', + collapsed: true, // capitolo: chiuso + items: [ + // lezione con solo esercizi rapidi → pagina singola + 'programmatore/le-basi/le-variabili', + // lezione con più tipi di pagina → categoria + { + type: 'category', + label: 'I tipi di dato', + collapsed: true, // lezione: chiusa + items: [ + 'programmatore/le-basi/i-tipi-di-dato/esercizi-rapidi', + 'programmatore/le-basi/i-tipi-di-dato/esercizio-convertitore', + 'programmatore/le-basi/i-tipi-di-dato/laboratorio-convertitore', + ], + }, + ], + }, + ], + }, +]; diff --git a/curriculum/toc/archivista.js b/curriculum/toc/archivista.js new file mode 100644 index 0000000..864b121 --- /dev/null +++ b/curriculum/toc/archivista.js @@ -0,0 +1,3 @@ +// Albero sidebar UNICO del Manuale dell'Archivista (Volume 3 — 5a). +// Vedi programmatore.js per il ruolo di questi file. +module.exports = ['intro']; diff --git a/curriculum/toc/artefice.js b/curriculum/toc/artefice.js new file mode 100644 index 0000000..916d491 --- /dev/null +++ b/curriculum/toc/artefice.js @@ -0,0 +1,10 @@ +// Albero sidebar UNICO del Manuale dell'Artefice (Volume 2 — 4a). +// Vedi programmatore.js per il ruolo di questi file. +module.exports = [ + 'intro', + 'perche-gli-oggetti', + 'classi-e-istanze', + 'metodi-di-classe-e-statici', + 'mostrare-un-oggetto', + 'incapsulamento', +]; diff --git a/curriculum/toc/programmatore.js b/curriculum/toc/programmatore.js new file mode 100644 index 0000000..fa95399 --- /dev/null +++ b/curriculum/toc/programmatore.js @@ -0,0 +1,5 @@ +// Albero sidebar UNICO del Manuale del Programmatore (Volume 1 — 3a). +// Unione dei contenuti dei vecchi percorsi it/liceo/its. Fonte unica sia per +// la sidebar Docusaurus (sidebars/programmatore.ts) sia per il plugin +// curriculum (manifest + configuratore). CJS: require()-abile da entrambi. +module.exports = ['intro', 'variabili']; diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 5985ed5..4e79104 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -129,6 +129,7 @@ export default async function createConfig(): Promise { './plugins/pyrunner/index.js', './plugins/exercise-graph/index.js', './plugins/copy-page-md/index.js', + './plugins/curriculum/index.js', [ '@docusaurus/plugin-content-docs', { diff --git a/package-lock.json b/package-lock.json index 3ba50be..78b54bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "python-doesnt-byte", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "python-doesnt-byte", - "version": "0.12.0", + "version": "0.13.0", "dependencies": { "@codemirror/commands": "^6.10.3", "@codemirror/lang-python": "^6.2.1", @@ -31,6 +31,7 @@ "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.2", "prism-react-renderer": "^2.3.0", + "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react": "^18.0.0", "react-dom": "^18.0.0", @@ -41,6 +42,7 @@ "@docusaurus/module-type-aliases": "^3.10.1", "@docusaurus/tsconfig": "^3.10.1", "@docusaurus/types": "^3.10.1", + "@types/qrcode": "^1.5.6", "@types/sql.js": "^1.4.11", "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", @@ -5993,6 +5995,16 @@ "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", @@ -8161,6 +8173,51 @@ "node": ">=8" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -9625,6 +9682,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -9843,6 +9909,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -11775,6 +11847,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -17320,6 +17401,15 @@ "node": ">=8" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", @@ -17595,6 +17685,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -19293,6 +19392,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -20140,6 +20256,15 @@ "entities": "^2.0.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -20157,6 +20282,12 @@ "node": "*" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -20829,6 +20960,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -23289,9 +23426,9 @@ } }, "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", @@ -23400,6 +23537,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -23622,12 +23765,143 @@ "xml-js": "bin/cli.js" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-parser/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", diff --git a/package.json b/package.json index 75c3250..4d9ec1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "python-doesnt-byte", - "version": "0.12.0", + "version": "0.13.0", "private": true, "scripts": { "docusaurus": "docusaurus", @@ -17,6 +17,8 @@ "lint": "eslint src --ext .js,.jsx,.ts,.tsx", "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,md,mdx}\"", + "curriculum:new-epoch": "node scripts/curriculum-new-epoch.js", + "curriculum:check": "node scripts/check-manifest-append-only.js", "version": "node scripts/sync-pm-version.mjs" }, "dependencies": { @@ -43,6 +45,7 @@ "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.2", "prism-react-renderer": "^2.3.0", + "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react": "^18.0.0", "react-dom": "^18.0.0", @@ -53,6 +56,7 @@ "@docusaurus/module-type-aliases": "^3.10.1", "@docusaurus/tsconfig": "^3.10.1", "@docusaurus/types": "^3.10.1", + "@types/qrcode": "^1.5.6", "@types/sql.js": "^1.4.11", "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", @@ -68,7 +72,8 @@ }, "overrides": { "webpack-dev-server": "^5.2.1", - "shell-quote": "^1.8.4" + "shell-quote": "^1.8.4", + "websocket-driver": "^0.7.5" }, "browserslist": { "production": [ diff --git a/plugins/curriculum/index.js b/plugins/curriculum/index.js new file mode 100644 index 0000000..a95e921 --- /dev/null +++ b/plugins/curriculum/index.js @@ -0,0 +1,279 @@ +const path = require('path'); +const fs = require('fs'); +const { + parseMarkdownFile, + DEFAULT_PARSE_FRONT_MATTER, +} = require('@docusaurus/utils'); + +const PLUGIN_NAME = 'curriculum'; + +// Volumi (istanze plugin-content-docs) in ordine curricolare: è l'ordine dei +// bit nel manifest e delle sezioni nel configuratore. +const VOLUMES = [ + { id: 'programmatore', label: 'Manuale del Programmatore' }, + { id: 'artefice', label: 'Manuale dell’Artefice' }, + { id: 'archivista', label: 'Manuale dell’Archivista' }, + { id: 'apprendista', label: 'Biblioteca dell’Apprendista' }, +]; + +const MD_EXT = /\.mdx?$/; + +// ── Helper condivisi con plugins/exercise-graph (stessa convenzione docId) ── + +function humanize(segment) { + const s = segment.replace(/[-_]+/g, ' ').trim(); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function walkDocs(dir, baseDir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDocs(full, baseDir, out); + } else if (entry.isFile() && MD_EXT.test(entry.name)) { + const rel = path + .relative(baseDir, full) + .split(path.sep) + .join('/') + .replace(MD_EXT, ''); + out.push({ filePath: full, relId: rel }); + } + } + return out; +} + +function resolveDocId(relId, frontMatter) { + const override = frontMatter.id; + if (typeof override !== 'string' || override.length === 0) return relId; + const slash = relId.lastIndexOf('/'); + return slash === -1 ? override : `${relId.slice(0, slash + 1)}${override}`; +} + +async function parseDoc(filePath) { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + return parseMarkdownFile({ + filePath, + fileContent, + parseFrontMatter: DEFAULT_PARSE_FRONT_MATTER, + removeContentTitle: true, + }); +} + +// ── TOC ───────────────────────────────────────────────────────────────────── + +// require() con cache bustata: in dev il watcher rilancia loadContent quando +// un file TOC cambia, ma senza questo il require riconsegnerebbe il modulo +// vecchio. +function freshRequire(modulePath) { + delete require.cache[require.resolve(modulePath)]; + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require(modulePath); +} + +// Appiattisce un albero sidebar (stringhe doc + categorie annidate) nella +// lista ordinata dei docId volume-local. +function flattenTocDocIds(items, tocPath, out = []) { + for (const item of items) { + if (typeof item === 'string') { + out.push(item); + } else if (item && item.type === 'doc' && typeof item.id === 'string') { + out.push(item.id); + } else if (item && item.type === 'category' && Array.isArray(item.items)) { + flattenTocDocIds(item.items, tocPath, out); + } else { + throw new Error( + `[${PLUGIN_NAME}] Voce TOC non riconosciuta in ${tocPath}: ` + + `${JSON.stringify(item)}. Attesi: docId stringa, {type:'doc'}, {type:'category'}.`, + ); + } + } + return out; +} + +// Clona l'albero TOC per il client, risolvendo i doc in { key, title }. +function buildClientTree(items, volumeId, titles) { + return items.map((item) => { + if (typeof item === 'string' || (item && item.type === 'doc')) { + const docId = typeof item === 'string' ? item : item.id; + const key = `${volumeId}/${docId}`; + return { type: 'doc', key, title: titles[key] }; + } + return { + type: 'category', + label: item.label, + items: buildClientTree(item.items, volumeId, titles), + }; + }); +} + +// ── Manifest ──────────────────────────────────────────────────────────────── + +function readManifest(manifestPath) { + if (!fs.existsSync(manifestPath)) return null; + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + if ( + manifest.version !== 1 || + !Number.isInteger(manifest.currentEpoch) || + typeof manifest.epochs !== 'object' || + manifest.epochs === null || + !Array.isArray(manifest.epochs[String(manifest.currentEpoch)]) + ) { + throw new Error( + `[${PLUGIN_NAME}] ${manifestPath} malformato: attesi version=1, ` + + `currentEpoch intero e epochs[""] array.`, + ); + } + for (const [epoch, entries] of Object.entries(manifest.epochs)) { + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry)) { + throw new Error( + `[${PLUGIN_NAME}] Entry duplicata nel manifest (epoca ${epoch}): «${entry}».`, + ); + } + seen.add(entry); + } + } + return manifest; +} + +function writeManifest(manifestPath, manifest) { + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} + +module.exports = function curriculumPlugin(context) { + const siteDir = context.siteDir; + const tocDir = path.join(siteDir, 'curriculum', 'toc'); + const presetsPath = path.join(siteDir, 'curriculum', 'presets.js'); + const manifestPath = path.join(siteDir, 'curriculum', 'manifest.json'); + const volumeDir = (v) => path.join(siteDir, 'volumes', v); + + return { + name: PLUGIN_NAME, + + async loadContent() { + const isProduction = process.env.NODE_ENV === 'production'; + + // ── TOC + titoli ────────────────────────────────────────────────────── + // titles["/"] — risolti dal frontmatter come in + // exercise-graph (title > sidebar_label > H1 > humanize). + const titles = {}; + const volumes = []; + const tocKeys = []; // tutte le lezioni, in ordine curricolare + + for (const { id: volumeId, label } of VOLUMES) { + const tocPath = path.join(tocDir, `${volumeId}.js`); + const tree = freshRequire(tocPath); + const docIds = flattenTocDocIds(tree, tocPath); + + const base = volumeDir(volumeId); + const files = walkDocs(base, base); + const byDocId = {}; + await Promise.all( + files.map(async ({ filePath, relId }) => { + const { frontMatter, contentTitle } = await parseDoc(filePath); + const docId = resolveDocId(relId, frontMatter); + byDocId[docId] = { filePath, frontMatter, contentTitle }; + }), + ); + + for (const docId of docIds) { + const doc = byDocId[docId]; + if (!doc) { + throw new Error( + `[${PLUGIN_NAME}] ${tocPath}: il doc «${docId}» non esiste in ` + + `volumes/${volumeId}/.`, + ); + } + const { frontMatter, contentTitle } = doc; + titles[`${volumeId}/${docId}`] = + (typeof frontMatter.title === 'string' && frontMatter.title) || + (typeof frontMatter.sidebar_label === 'string' && + frontMatter.sidebar_label) || + contentTitle || + humanize(docId.split('/').pop()); + tocKeys.push(`${volumeId}/${docId}`); + } + + volumes.push({ + id: volumeId, + label, + tree: buildClientTree(tree, volumeId, titles), + }); + } + + // ── Manifest: valida/appendi (epoca corrente) ───────────────────────── + let manifest = readManifest(manifestPath); + if (!manifest) { + if (isProduction) { + throw new Error( + `[${PLUGIN_NAME}] curriculum/manifest.json mancante. Lancia ` + + `\`npm start\` per generarlo e committalo.`, + ); + } + manifest = { version: 1, currentEpoch: 1, epochs: { 1: [] } }; + } + + const epochKey = String(manifest.currentEpoch); + const current = manifest.epochs[epochKey]; + const known = new Set(current); + const missing = tocKeys.filter((key) => !known.has(key)); + if (missing.length > 0) { + if (isProduction) { + throw new Error( + `[${PLUGIN_NAME}] ${missing.length} lezioni della TOC mancano dal ` + + `manifest (epoca ${epochKey}): ${missing.join(', ')}. Il manifest ` + + `committato è la fonte unica delle posizioni di bit: lancia ` + + `\`npm start\` (le appende) e committa curriculum/manifest.json.`, + ); + } + manifest.epochs[epochKey] = [...current, ...missing]; + writeManifest(manifestPath, manifest); + console.log( + `[${PLUGIN_NAME}] Appese ${missing.length} lezioni al manifest ` + + `(epoca ${epochKey}): ${missing.join(', ')} — ricordati di ` + + `committare curriculum/manifest.json.`, + ); + } + + // ── Preset: ogni chiave deve esistere nell'epoca corrente ──────────── + const presets = freshRequire(presetsPath); + const currentSet = new Set(manifest.epochs[epochKey]); + for (const preset of presets) { + for (const key of preset.keys) { + if (!currentSet.has(key)) { + throw new Error( + `[${PLUGIN_NAME}] Il preset «${preset.id}» referenzia ` + + `«${key}», assente dall'epoca corrente del manifest.`, + ); + } + } + } + + // ── Pagine sempre visibili: le intro dei volumi ─────────────────────── + const alwaysVisible = VOLUMES.map((v) => `${v.id}/intro`).filter((key) => + tocKeys.includes(key), + ); + + return { volumes, manifest, presets, alwaysVisible }; + }, + + async contentLoaded({ content, actions }) { + actions.setGlobalData(content); + }, + + getPathsToWatch() { + // NB: NON manifest.json — il plugin lo scrive in dev, watcharlo + // innescherebbe un loop di rebuild. + return [path.join(tocDir, '*.js'), presetsPath]; + }, + }; +}; + +module.exports.PLUGIN_NAME = PLUGIN_NAME; +module.exports.VOLUMES = VOLUMES; +// Riusato da scripts/curriculum-new-epoch.js per rigenerare l'elenco +// in ordine curricolare. +module.exports.flattenTocDocIds = flattenTocDocIds; diff --git a/scripts/check-manifest-append-only.js b/scripts/check-manifest-append-only.js new file mode 100644 index 0000000..6725e53 --- /dev/null +++ b/scripts/check-manifest-append-only.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * Guardia CI per curriculum/manifest.json: le posizioni di bit sono la + * base dei codici percorso distribuiti alle classi, quindi rispetto alla + * copia su un ref di riferimento (default origin/main) valgono tre regole: + * + * 1. le epoche passate sono IMMUTABILI (byte per byte); + * 2. l'epoca corrente è APPEND-ONLY (la vecchia lista deve esserne un + * prefisso); + * 3. `currentEpoch` può solo restare uguale o avanzare di 1 (apertura di + * epoca via scripts/curriculum-new-epoch.js, che congela la precedente). + * + * Se il ref non ha il manifest (repo/branch senza il file) il check passa + * con un avviso: non c'è nulla da proteggere. + * + * Uso: node scripts/check-manifest-append-only.js [ref] (default origin/main) + */ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const REL = 'curriculum/manifest.json'; +const ref = process.argv[2] || 'origin/main'; + +function fail(msg) { + console.error(`[manifest-check] VIOLAZIONE: ${msg}`); + console.error( + '[manifest-check] I codici percorso distribuiti dipendono dalle posizioni ' + + 'nel manifest: correggi la modifica (le lezioni nuove si APPENDONO in ' + + 'coda; per compattare/riordinare apri una nuova epoca con ' + + '`npm run curriculum:new-epoch`).', + ); + process.exit(1); +} + +let oldRaw; +try { + oldRaw = execSync(`git show ${ref}:${REL}`, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} catch { + console.log( + `[manifest-check] ${ref}:${REL} non disponibile — nulla da confrontare, check saltato.`, + ); + process.exit(0); +} + +const before = JSON.parse(oldRaw); +const after = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', REL), 'utf-8'), +); + +if (after.currentEpoch < before.currentEpoch) { + fail(`currentEpoch regredito: ${before.currentEpoch} → ${after.currentEpoch}.`); +} +if (after.currentEpoch > before.currentEpoch + 1) { + fail( + `currentEpoch avanzato di più di 1 (${before.currentEpoch} → ` + + `${after.currentEpoch}): le epoche si aprono una alla volta.`, + ); +} + +for (const [epoch, entries] of Object.entries(before.epochs)) { + const now = after.epochs[epoch]; + if (!Array.isArray(now)) { + fail(`l'epoca ${epoch} è sparita dal manifest.`); + } + const isPast = + Number(epoch) < before.currentEpoch || + (Number(epoch) === before.currentEpoch && + after.currentEpoch === before.currentEpoch + 1); + if (isPast) { + // Epoca congelata: identità totale. + if ( + now.length !== entries.length || + entries.some((k, i) => now[i] !== k) + ) { + fail(`l'epoca ${epoch} (congelata) è stata modificata.`); + } + } else { + // Epoca corrente: la vecchia lista deve essere un prefisso della nuova. + if (now.length < entries.length || entries.some((k, i) => now[i] !== k)) { + fail( + `l'epoca corrente ${epoch} non è append-only rispetto a ${ref} ` + + `(entry riordinate, rimosse o riscritte).`, + ); + } + } +} + +if (after.currentEpoch === before.currentEpoch + 1) { + const opened = after.epochs[String(after.currentEpoch)]; + if (!Array.isArray(opened) || opened.length === 0) { + fail( + `currentEpoch dichiara l'epoca ${after.currentEpoch} ma la sua lista ` + + 'manca o è vuota.', + ); + } +} + +console.log( + `[manifest-check] OK rispetto a ${ref} (epoca corrente ${after.currentEpoch}, ` + + `${after.epochs[String(after.currentEpoch)].length} lezioni).`, +); diff --git a/scripts/curriculum-new-epoch.js b/scripts/curriculum-new-epoch.js new file mode 100644 index 0000000..0493a5e --- /dev/null +++ b/scripts/curriculum-new-epoch.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Apertura di una nuova epoca del manifest percorsi (rito annuale, di norma + * a inizio anno scolastico). Vedi pm/design-percorsi-personalizzati.md. + * + * Rigenera l'elenco delle lezioni dalla TOC (curriculum/toc/*.js) in ordine + * curricolare, senza tombstone, lo salva come epoca `currentEpoch + 1` e + * aggiorna `currentEpoch`. L'epoca precedente resta nel file, congelata: + * i codici già distribuiti continuano a decodificare contro di essa. + * + * Se l'epoca corrente coincide già con la TOC (niente tombstone, niente + * riordino) non c'è nulla da compattare: lo script non tocca il file, + * a meno di `--force`. + * + * Uso: npm run curriculum:new-epoch [-- --force] + */ +const path = require('path'); +const fs = require('fs'); + +const { VOLUMES, flattenTocDocIds } = require('../plugins/curriculum'); + +const ROOT = path.join(__dirname, '..'); +const MANIFEST = path.join(ROOT, 'curriculum', 'manifest.json'); +const force = process.argv.includes('--force'); + +const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf-8')); +const epochKey = String(manifest.currentEpoch); +const current = manifest.epochs[epochKey]; +if (!Array.isArray(current)) { + console.error(`[new-epoch] Manifest malformato: epochs["${epochKey}"] assente.`); + process.exit(1); +} + +// Elenco fresco in ordine curricolare, dalla TOC. +const fresh = []; +for (const { id: volumeId } of VOLUMES) { + const tocPath = path.join(ROOT, 'curriculum', 'toc', `${volumeId}.js`); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const tree = require(tocPath); + for (const docId of flattenTocDocIds(tree, tocPath)) { + fresh.push(`${volumeId}/${docId}`); + } +} + +const identical = + fresh.length === current.length && fresh.every((k, i) => k === current[i]); +if (identical && !force) { + console.log( + `[new-epoch] L'epoca ${epochKey} coincide già con la TOC ` + + `(${current.length} lezioni, nessun tombstone, ordine curricolare): ` + + `nulla da compattare. Usa --force per aprire comunque una nuova epoca.`, + ); + process.exit(0); +} + +const next = manifest.currentEpoch + 1; +if (next > 255) { + console.error('[new-epoch] Epoca massima (255) raggiunta: serve un formato v2.'); + process.exit(1); +} + +const tombstones = current.filter((k) => !fresh.includes(k)); +manifest.epochs[String(next)] = fresh; +manifest.currentEpoch = next; +fs.writeFileSync(MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`); + +console.log(`[new-epoch] Aperta l'epoca ${next}:`); +console.log(` - lezioni: ${fresh.length} (erano ${current.length})`); +console.log(` - tombstone eliminati: ${tombstones.length}${tombstones.length ? ` (${tombstones.join(', ')})` : ''}`); +console.log( + ` - l'epoca ${epochKey} resta congelata nel manifest: i codici già ` + + `distribuiti continuano a funzionare.`, +); +console.log( + ' - da ora il configuratore genera codici della nuova epoca. ' + + 'Committa curriculum/manifest.json.', +); diff --git a/sidebars/apprendista.ts b/sidebars/apprendista.ts index 0146468..aaa2bce 100644 --- a/sidebars/apprendista.ts +++ b/sidebars/apprendista.ts @@ -1,76 +1,14 @@ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; // Biblioteca dell'Apprendista (Volume 4 — esercizi e laboratori). -// -// Gli esercizi sono raggruppati per volume di provenienza: -// volume-sorgente > capitolo > lezione -// -// Modello IBRIDO al livello "lezione": -// - lezione con SOLO esercizi rapidi → è una PAGINA singola (un doc) -// - lezione con anche esercizi dedicati / laboratori → è una CATEGORIA, con -// sotto una pagina per tipo (Esercizi rapidi / Esercizio: … / Laboratorio: …) -// Gli esercizietti di una pagina-batch sono sezioni

(vanno nel TOC della -// pagina), non voci di sidebar. -// -// Regola di collapse (per evitare la "lista infinita"): -// - categorie volume-sorgente: collapsed: false → aperte, mostrano i capitoli -// - categorie capitolo / lezione: collapsed: true → chiuse di default -// Così a colpo d'occhio si vedono i volumi-sorgente e l'elenco dei capitoli; -// il resto resta nascosto finché non si espande. -// -// NB: `themeConfig.docs.sidebar.autoCollapseCategories` è GLOBALE ed è a `true`: -// aprendo un capitolo, i fratelli si richiudono. Per questa sidebar sarebbe -// preferibile `false` (vedi nota in docusaurus.config.ts). - -// Albero condiviso dai tre percorsi (it / liceo / its). Quando i percorsi -// divergeranno, duplicare e personalizzare per chiave. -const tree: SidebarsConfig[string] = [ - 'intro', - { - type: 'category', - label: 'Manuale del Programmatore', - collapsed: false, // volume-sorgente: aperto - items: [ - { - type: 'category', - label: 'Le basi', - collapsed: true, // capitolo: chiuso - items: [ - // lezione con solo esercizi rapidi → pagina singola - 'programmatore/le-basi/le-variabili', - // lezione con più tipi di pagina → categoria - { - type: 'category', - label: 'I tipi di dato', - collapsed: true, // lezione: chiusa - items: [ - 'programmatore/le-basi/i-tipi-di-dato/esercizi-rapidi', - 'programmatore/le-basi/i-tipi-di-dato/esercizio-convertitore', - 'programmatore/le-basi/i-tipi-di-dato/laboratorio-convertitore', - ], - }, - ], - }, - ], - }, - // { - // type: 'category', - // label: 'Manuale dell'Artefice', - // collapsed: false, - // items: [ /* capitoli > lezioni > esercizi */ ], - // }, - // { - // type: 'category', - // label: 'Manuale dell'Archivista', - // collapsed: false, - // items: [ /* capitoli > lezioni > esercizi */ ], - // }, -]; +// Sidebar UNICA: l'albero (struttura ibrida pagina/categoria per lezione, +// regole di collapse) vive in curriculum/toc/apprendista.js — fonte condivisa +// con il plugin curriculum; i percorsi personalizzati filtrano client-side. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const tree = require('../curriculum/toc/apprendista.js'); const sidebars: SidebarsConfig = { - it: tree, - liceo: tree, - its: tree, + libro: tree, }; export default sidebars; diff --git a/sidebars/archivista.ts b/sidebars/archivista.ts index 86c6c44..4ad6746 100644 --- a/sidebars/archivista.ts +++ b/sidebars/archivista.ts @@ -1,10 +1,13 @@ -import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; +import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; // Manuale dell'Archivista (Volume 3 — 5a). +// Sidebar UNICA: l'albero vive in curriculum/toc/ (fonte condivisa con il +// plugin curriculum); i percorsi personalizzati filtrano client-side. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const tree = require('../curriculum/toc/archivista.js'); + const sidebars: SidebarsConfig = { - it: ['intro'], - liceo: ['intro'], - its: ['intro'], + libro: tree, }; export default sidebars; diff --git a/sidebars/artefice.ts b/sidebars/artefice.ts index 90a5a84..e1340f4 100644 --- a/sidebars/artefice.ts +++ b/sidebars/artefice.ts @@ -1,31 +1,13 @@ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; // Manuale dell'Artefice (Volume 2 — 4a). +// Sidebar UNICA: l'albero vive in curriculum/toc/ (fonte condivisa con il +// plugin curriculum); i percorsi personalizzati filtrano client-side. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const tree = require('../curriculum/toc/artefice.js'); + const sidebars: SidebarsConfig = { - it: [ - 'intro', - 'perche-gli-oggetti', - 'classi-e-istanze', - 'metodi-di-classe-e-statici', - 'mostrare-un-oggetto', - 'incapsulamento', - ], - liceo: [ - 'intro', - 'perche-gli-oggetti', - 'classi-e-istanze', - 'metodi-di-classe-e-statici', - 'mostrare-un-oggetto', - 'incapsulamento', - ], - its: [ - 'intro', - 'perche-gli-oggetti', - 'classi-e-istanze', - 'metodi-di-classe-e-statici', - 'mostrare-un-oggetto', - 'incapsulamento', - ], + libro: tree, }; export default sidebars; diff --git a/sidebars/programmatore.ts b/sidebars/programmatore.ts index 8a084f4..38f8a30 100644 --- a/sidebars/programmatore.ts +++ b/sidebars/programmatore.ts @@ -1,12 +1,13 @@ -import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; +import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; // Manuale del Programmatore (Volume 1 — 3a). -// Una sidebar per percorso. Lo stesso doc può comparire in più percorsi -// con label/categoria diverse: l'URL del doc resta unico. +// Sidebar UNICA: l'albero vive in curriculum/toc/ (fonte condivisa con il +// plugin curriculum); i percorsi personalizzati filtrano client-side. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const tree = require('../curriculum/toc/programmatore.js'); + const sidebars: SidebarsConfig = { - it: ['intro', 'variabili'], - liceo: ['intro', 'variabili'], - its: ['intro'], + libro: tree, }; export default sidebars; diff --git a/src/components/CurriculumIndicator/index.tsx b/src/components/CurriculumIndicator/index.tsx new file mode 100644 index 0000000..c1e24df --- /dev/null +++ b/src/components/CurriculumIndicator/index.tsx @@ -0,0 +1,59 @@ +/** + * CurriculumIndicator — chip in navbar (slot center) quando un percorso è + * attivo: mostra la label del preset se il codice coincide con uno dei + * preset (confronto stringa sulla forma canonica), altrimenti «Percorso + * attivo». Link al configuratore, «×» per rimuovere. Sostituisce + * PathSelector del vecchio sistema a sidebar multiple. + */ +import React, { useMemo, type ReactNode } from 'react'; +import Link from '@docusaurus/Link'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + useCurriculum, + useCurriculumPluginData, +} from '@site/src/contexts/CurriculumContext'; +import { codeFromKeys } from '@site/src/lib/curriculumSelection'; + +import styles from './styles.module.css'; + +export default function CurriculumIndicator(): ReactNode { + const { code, clear } = useCurriculum(); + const { manifest, presets } = useCurriculumPluginData(); + + const presetLabel = useMemo(() => { + if (!code) return null; + const match = presets.find( + (p) => codeFromKeys(new Set(p.keys), manifest) === code, + ); + return match?.label ?? null; + }, [code, presets, manifest]); + + if (!code) return null; + + return ( +
+ +
+ ); +} diff --git a/src/components/CurriculumIndicator/styles.module.css b/src/components/CurriculumIndicator/styles.module.css new file mode 100644 index 0000000..6a4eb34 --- /dev/null +++ b/src/components/CurriculumIndicator/styles.module.css @@ -0,0 +1,67 @@ +.wrap { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + background: var(--at-accent-chip); + border: 1px solid var(--at-accent-chip-border); + border-radius: var(--radius-pill); + margin: 0 8px; +} + +.label { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono-ui); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + padding: 4px 6px 4px 10px; + border-radius: var(--radius-pill); + color: var(--at-accent-soft); + transition: color var(--dur-fast) var(--ease-smooth); +} + +.label:hover { + color: var(--at-fg-strong); + text-decoration: none; +} + +.label:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 1px; +} + +.icon { + font-size: 11px; +} + +.close { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--radius-circle); + color: var(--at-muted); + cursor: pointer; + font-size: 11px; + transition: + color var(--dur-fast) var(--ease-smooth), + background var(--dur-fast) var(--ease-smooth); +} + +.close:hover { + color: var(--at-fg-strong); + background: var(--at-bg-chip); +} + +.close:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 1px; +} diff --git a/src/components/OffCurriculumBanner/index.tsx b/src/components/OffCurriculumBanner/index.tsx new file mode 100644 index 0000000..a15514c --- /dev/null +++ b/src/components/OffCurriculumBanner/index.tsx @@ -0,0 +1,56 @@ +/** + * OffCurriculumBanner — avviso in cima a una lezione aperta via link diretto + * quando NON fa parte del percorso attivo (curazione, non censura: la pagina + * resta leggibile). Offre la rimozione del percorso e il link al + * configuratore. Sostituisce OffPathBanner (vecchio sistema a sidebar + * multiple). + */ +import React, { type ReactNode } from 'react'; +import Link from '@docusaurus/Link'; +import { useDoc, useDocsVersion } from '@docusaurus/plugin-content-docs/client'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + useCurriculum, + useCurriculumPluginData, +} from '@site/src/contexts/CurriculumContext'; + +import styles from './styles.module.css'; + +export default function OffCurriculumBanner(): ReactNode { + const { metadata } = useDoc(); + const version = useDocsVersion(); + const { included, isIncluded, clear } = useCurriculum(); + const { volumes } = useCurriculumPluginData(); + + if (!included) return null; // nessun percorso attivo + if (!volumes.some((v) => v.id === version.pluginId)) return null; + if (isIncluded(`${version.pluginId}/${metadata.id}`)) return null; + + return ( + + ); +} diff --git a/src/components/OffPathBanner/styles.module.css b/src/components/OffCurriculumBanner/styles.module.css similarity index 72% rename from src/components/OffPathBanner/styles.module.css rename to src/components/OffCurriculumBanner/styles.module.css index b3d4c2a..8465c21 100644 --- a/src/components/OffPathBanner/styles.module.css +++ b/src/components/OffCurriculumBanner/styles.module.css @@ -1,13 +1,13 @@ .banner { display: flex; align-items: center; + flex-wrap: wrap; gap: 10px; padding: 10px 14px; margin: 0 0 24px; border-radius: var(--radius-md); background: rgba(245, 158, 11, 0.06); border: 1px solid rgba(245, 158, 11, 0.28); - border-left-width: 3px; color: var(--at-fg-body); font-family: var(--font-body); font-size: 1rem; @@ -37,33 +37,13 @@ html[data-theme='dark'] .icon { color: var(--at-fg); } -.tag { - font-family: var(--font-mono-ui); - font-size: 0.82em; - font-weight: 600; - letter-spacing: 0.04em; - padding: 1px 7px; - border-radius: var(--radius-xs); - background: rgba(245, 158, 11, 0.14); - color: #b45309; -} - -html[data-theme='dark'] .tag { - background: rgba(251, 191, 36, 0.14); - color: #fbbf24; -} - -.sep { - display: inline-block; - margin: 0 8px; - color: var(--at-faint); -} - -.minisep { - color: var(--at-faint); +.actions { + display: inline-flex; + align-items: center; + white-space: nowrap; } -.switch { +.action { appearance: none; background: none; border: none; @@ -79,6 +59,13 @@ html[data-theme='dark'] .tag { text-underline-offset: 2px; } -.switch:hover { +.action:hover { color: var(--at-accent-soft); + text-decoration: underline; +} + +.sep { + display: inline-block; + margin: 0 8px; + color: var(--at-faint); } diff --git a/src/components/OffPathBanner/index.tsx b/src/components/OffPathBanner/index.tsx deleted file mode 100644 index 8774f4a..0000000 --- a/src/components/OffPathBanner/index.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * OffPathBanner — avviso mostrato in cima alla pagina di una lezione quando - * la lezione NON fa parte del percorso correntemente scelto dall'utente. - * Elenca in quali altri percorsi del volume la lezione è presente e offre - * un bottone per switchare il percorso (la sidebar si ricostruisce in - * place senza navigare). - */ -import React, { type ReactNode } from 'react'; -import { useDoc, useDocsVersion } from '@docusaurus/plugin-content-docs/client'; -import type { - PropSidebar, - PropSidebarItem, -} from '@docusaurus/plugin-content-docs'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { usePathContext, type VolumeId } from '@site/src/contexts/PathContext'; -import { pathLabel } from '@site/src/contexts/pathLabels'; - -import styles from './styles.module.css'; - -const VOLUMES: ReadonlySet = new Set([ - 'programmatore', - 'artefice', - 'archivista', - 'apprendista', -]); - -function sidebarContainsDoc(items: PropSidebar, docId: string): boolean { - for (const item of items as PropSidebarItem[]) { - if (item.type === 'link' && item.docId === docId) return true; - if (item.type === 'category' && sidebarContainsDoc(item.items, docId)) - return true; - } - return false; -} - -function useOffPathInfo() { - const { metadata } = useDoc(); - const version = useDocsVersion(); - const { getPath } = usePathContext(); - - if (!VOLUMES.has(version.pluginId)) return null; - const volumeId = version.pluginId as VolumeId; - const chosen = getPath(volumeId); - if (!chosen) return null; // utente non ha mai scelto → niente banner - - const sidebars = version.docsSidebars ?? {}; - const activeSidebar = sidebars[chosen]; - if (!activeSidebar) return null; - - if (sidebarContainsDoc(activeSidebar as PropSidebar, metadata.id)) { - return null; // la lezione è nel percorso attivo → tutto ok - } - - const availableIn = Object.entries(sidebars) - .filter( - ([name, sb]) => - name !== chosen && sidebarContainsDoc(sb as PropSidebar, metadata.id), - ) - .map(([name]) => name); - - return { volumeId, chosen, availableIn }; -} - -export default function OffPathBanner(): ReactNode { - const info = useOffPathInfo(); - const { setPath } = usePathContext(); - if (!info) return null; - const { volumeId, chosen, availableIn } = info; - - return ( - - ); -} diff --git a/src/components/PathSelector/index.tsx b/src/components/PathSelector/index.tsx deleted file mode 100644 index f15f942..0000000 --- a/src/components/PathSelector/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * PathSelector — segmented control nella navbar per scegliere il percorso - * (IT / Liceo / ITS ecc.) del volume correntemente visitato. Visibile solo - * quando l'utente è dentro un volume; nascosto altrove. - * - * I percorsi disponibili sono dedotti dalle sidebar dichiarate per il - * plugin-content-docs instance del volume attivo: ogni "sidebar name" - * = un percorso. - */ -import React, { type ReactNode } from 'react'; -import { - useActivePlugin, - useAllDocsData, -} from '@docusaurus/plugin-content-docs/client'; -import { usePathContext, type VolumeId } from '@site/src/contexts/PathContext'; -import { pathLabel } from '@site/src/contexts/pathLabels'; - -import styles from './styles.module.css'; - -const VOLUMES: ReadonlySet = new Set([ - 'programmatore', - 'artefice', - 'archivista', - 'apprendista', -]); - -export default function PathSelector(): ReactNode { - const activePlugin = useActivePlugin(); - const allData = useAllDocsData(); - const { getPath, setPath } = usePathContext(); - - if (!activePlugin || !VOLUMES.has(activePlugin.pluginId)) { - return null; - } - const volumeId = activePlugin.pluginId as VolumeId; - - // Ricavo i nomi delle sidebar (= percorsi) dichiarate per questa istanza - // leggendo i metadata della versione "current". - const pluginData = allData[volumeId]; - const version = pluginData?.versions?.find((v) => v.name === 'current'); - if (!version) return null; - const pathIds = Object.keys(version.sidebars ?? {}); - if (pathIds.length <= 1) return null; - - // Default: primo percorso dichiarato per il volume. Se l'utente non ha - // ancora scelto nulla, non scriviamo nulla in localStorage — solo evidenza - // visiva. - const stored = getPath(volumeId); - const active = stored ?? pathIds[0]; - - return ( -
- {pathIds.map((id) => ( - - ))} -
- ); -} diff --git a/src/components/PathSelector/styles.module.css b/src/components/PathSelector/styles.module.css deleted file mode 100644 index 95e13bb..0000000 --- a/src/components/PathSelector/styles.module.css +++ /dev/null @@ -1,43 +0,0 @@ -.wrap { - display: inline-flex; - align-items: center; - gap: 2px; - padding: 2px; - background: var(--at-bg-subtle); - border: 1px solid var(--at-border); - border-radius: var(--radius-pill); - margin: 0 8px; -} - -.btn { - appearance: none; - background: transparent; - border: none; - border-radius: var(--radius-pill); - cursor: pointer; - font-family: var(--font-mono-ui); - font-size: 12px; - font-weight: 500; - letter-spacing: 0.05em; - padding: 4px 10px; - color: var(--at-muted); - transition: - background 0.15s ease, - color 0.15s ease; -} - -.btn:hover { - color: var(--at-fg-strong); -} - -.btn.active { - background: var(--at-accent-bg); - color: var(--at-accent-soft); - font-weight: 600; - box-shadow: inset 0 0 0 1px var(--at-accent-chip-border); -} - -.btn:focus-visible { - outline: 2px solid var(--at-accent); - outline-offset: 1px; -} diff --git a/src/contexts/CurriculumContext.tsx b/src/contexts/CurriculumContext.tsx new file mode 100644 index 0000000..6316787 --- /dev/null +++ b/src/contexts/CurriculumContext.tsx @@ -0,0 +1,226 @@ +/** + * CurriculumContext — percorso attivo per tutto il libro (codice `PDB1-…`). + * + * Il codice canonico vive in localStorage (`pdb:curriculum`); la decodifica + * avviene contro il manifest a epoche pubblicato dal plugin `curriculum` + * (usePluginData) e produce il set delle chiavi «/» incluse. + * + * SSR-safe per costruzione: il primo render (server e hydration) ha sempre + * `code = null` → `included = null` → libro intero; il valore reale arriva + * nell'effect al mount (two-pass hydration, stesso pattern di PathContext). + * + * Un componente interno legge `?percorso=CODE` dall'URL: se valido lo attiva + * e lo persiste, poi pulisce l'URL con history.replaceState. + */ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { useLocation } from '@docusaurus/router'; +import { usePluginData } from '@docusaurus/useGlobalData'; +import { normalizeCurriculumCode } from '@site/src/lib/curriculumCode'; +import { keysFromCode } from '@site/src/lib/curriculumSelection'; + +const STORAGE_KEY = 'pdb:curriculum'; + +/** Parametro URL per attivare un percorso via link condiviso. */ +export const CURRICULUM_QUERY_PARAM = 'percorso'; + +// ── Tipi del global data del plugin curriculum ────────────────────────────── + +export interface CurriculumManifest { + version: number; + currentEpoch: number; + epochs: Record; +} + +export interface CurriculumTreeDoc { + type: 'doc'; + key: string; + title: string; +} + +export interface CurriculumTreeCategory { + type: 'category'; + label: string; + items: CurriculumTreeItem[]; +} + +export type CurriculumTreeItem = CurriculumTreeDoc | CurriculumTreeCategory; + +export interface CurriculumVolume { + id: string; + label: string; + tree: CurriculumTreeItem[]; +} + +export interface CurriculumPreset { + id: string; + label: string; + short: string; + keys: string[]; +} + +export interface CurriculumPluginData { + volumes: CurriculumVolume[]; + manifest: CurriculumManifest; + presets: CurriculumPreset[]; + alwaysVisible: string[]; +} + +export function useCurriculumPluginData(): CurriculumPluginData { + return usePluginData('curriculum') as CurriculumPluginData; +} + +// ── Context ───────────────────────────────────────────────────────────────── + +type CurriculumContextValue = { + /** Codice canonico attivo, o null (nessun percorso: libro intero). */ + code: string | null; + /** + * Chiavi «/» incluse dal codice attivo, o null se non c'è + * codice (o il codice salvato non è più decodificabile → libro intero). + */ + included: ReadonlySet | null; + /** Visibilità di una lezione: sempre true senza percorso attivo. */ + isIncluded: (key: string) => boolean; + /** + * Valida, canonicalizza, attiva e persiste un codice. Lancia + * `CurriculumCodeError` (reason: crc/charset/version/epoch/…) se invalido. + */ + setCode: (raw: string) => void; + /** Rimuove il percorso attivo (torna al libro intero). */ + clear: () => void; +}; + +const CurriculumContext = createContext(null); + +export function CurriculumProvider({ children }: { children: ReactNode }) { + const { manifest, alwaysVisible } = useCurriculumPluginData(); + const [code, setCodeState] = useState(null); + + // Two-pass hydration: il setState sincrono al mount è voluto (pattern + // canonico per sincronizzare stato client-only dopo l'hydration SSR). + useEffect(() => { + try { + // eslint-disable-next-line react-hooks/set-state-in-effect + setCodeState(window.localStorage.getItem(STORAGE_KEY)); + } catch { + // localStorage disabilitato → silently ignore + } + }, []); + + // Cross-tab sync: attivazione/rimozione in un'altra tab si riflette qui. + useEffect(() => { + if (typeof window === 'undefined') return undefined; + const onStorage = (e: StorageEvent) => { + if (e.key !== STORAGE_KEY) return; + setCodeState(e.newValue); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const included = useMemo(() => { + if (!code) return null; + try { + return keysFromCode(code, manifest); + } catch { + // Codice salvato corrotto, epoca ignota o formato futuro: degrada a + // libro intero. + return null; + } + }, [code, manifest]); + + const alwaysVisibleSet = useMemo( + () => new Set(alwaysVisible), + [alwaysVisible], + ); + + const isIncluded = useCallback( + (key: string) => { + if (!included) return true; + return alwaysVisibleSet.has(key) || included.has(key); + }, + [included, alwaysVisibleSet], + ); + + const setCode = useCallback( + (raw: string) => { + const canonical = normalizeCurriculumCode(raw); + keysFromCode(canonical, manifest); // valida, epoca compresa (throw) + setCodeState(canonical); + try { + window.localStorage.setItem(STORAGE_KEY, canonical); + } catch { + /* ignore */ + } + }, + [manifest], + ); + + const clear = useCallback(() => { + setCodeState(null); + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } + }, []); + + const value = useMemo( + () => ({ code, included, isIncluded, setCode, clear }), + [code, included, isIncluded, setCode, clear], + ); + + return ( + + + {children} + + ); +} + +/** + * Attiva il percorso passato via `?percorso=CODE` (link condiviso dal + * docente) e pulisce l'URL. Vive dentro il provider, su ogni pagina. + */ +function CurriculumQueryParamSync() { + const location = useLocation(); + const { setCode } = useCurriculum(); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const raw = params.get(CURRICULUM_QUERY_PARAM); + if (raw === null) return; + try { + setCode(raw); + } catch (e) { + console.warn('[curriculum] Codice in ?percorso= non valido:', e); + } + params.delete(CURRICULUM_QUERY_PARAM); + const query = params.toString(); + window.history.replaceState( + null, + '', + `${location.pathname}${query ? `?${query}` : ''}${location.hash}`, + ); + }, [location, setCode]); + + return null; +} + +export function useCurriculum(): CurriculumContextValue { + const ctx = useContext(CurriculumContext); + if (!ctx) { + throw new Error( + 'useCurriculum deve essere usato dentro ', + ); + } + return ctx; +} diff --git a/src/contexts/PathContext.tsx b/src/contexts/PathContext.tsx deleted file mode 100644 index 8f15221..0000000 --- a/src/contexts/PathContext.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * PathContext — persistenza per-volume del percorso scelto dall'utente - * (es. IT / Liceo / ITS). Salvato in localStorage con chiave - * `pdb:path:`. Ogni volume sceglie autonomamente il suo set - * di percorsi disponibili (vedi sidebars/.ts). - */ -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode, -} from 'react'; - -export type VolumeId = - | 'programmatore' - | 'artefice' - | 'archivista' - | 'apprendista'; - -const STORAGE_PREFIX = 'pdb:path'; -const storageKey = (volume: VolumeId) => `${STORAGE_PREFIX}:${volume}`; - -type PathState = Partial>; - -type PathContextValue = { - /** Percorso correntemente attivo per il volume specificato (o null se mai scelto). */ - getPath: (volume: VolumeId) => string | null; - /** Imposta il percorso per un volume; persiste in localStorage. */ - setPath: (volume: VolumeId, path: string) => void; -}; - -const PathContext = createContext(null); - -function readInitialState(): PathState { - if (typeof window === 'undefined') return {}; - const out: PathState = {}; - for (const v of [ - 'programmatore', - 'artefice', - 'archivista', - 'apprendista', - ] as VolumeId[]) { - try { - const raw = window.localStorage.getItem(storageKey(v)); - if (raw) out[v] = raw; - } catch { - // localStorage disabilitato → silently ignore - } - } - return out; -} - -export function PathProvider({ children }: { children: ReactNode }) { - // Parte sempre da {} (come il render server-side) per evitare hydration - // mismatch; il valore reale da localStorage arriva nell'effect al mount. - const [state, setState] = useState({}); - - // Two-pass hydration: il setState sincrono al mount è voluto (è il pattern - // canonico per sincronizzare stato client-only dopo l'hydration SSR). - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setState(readInitialState()); - }, []); - - // Cross-tab sync: se l'utente cambia la scelta in un'altra tab, aggiorniamo qui. - useEffect(() => { - if (typeof window === 'undefined') return; - const onStorage = (e: StorageEvent) => { - if (!e.key?.startsWith(STORAGE_PREFIX + ':')) return; - const v = e.key.slice(STORAGE_PREFIX.length + 1) as VolumeId; - setState((prev) => ({ ...prev, [v]: e.newValue ?? undefined })); - }; - window.addEventListener('storage', onStorage); - return () => window.removeEventListener('storage', onStorage); - }, []); - - const getPath = useCallback( - (volume: VolumeId) => state[volume] ?? null, - [state], - ); - - const setPath = useCallback((volume: VolumeId, path: string) => { - setState((prev) => ({ ...prev, [volume]: path })); - try { - window.localStorage.setItem(storageKey(volume), path); - } catch { - /* ignore */ - } - }, []); - - const value = useMemo(() => ({ getPath, setPath }), [getPath, setPath]); - - return {children}; -} - -export function usePathContext(): PathContextValue { - const ctx = useContext(PathContext); - if (!ctx) { - throw new Error('usePathContext deve essere usato dentro '); - } - return ctx; -} diff --git a/src/contexts/pathLabels.ts b/src/contexts/pathLabels.ts deleted file mode 100644 index f330785..0000000 --- a/src/contexts/pathLabels.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Etichette umane dei percorsi didattici, condivise fra PathSelector, - * OffPathBanner e qualsiasi altro componente che debba mostrarle. - * Aggiungi qui se un volume introduce un nuovo percorso. - */ -export const PATH_LABEL: Record = { - it: 'Istituto Tecnico', - liceo: 'Liceo', - its: 'ITS', -}; - -/** Etichetta breve da usare nei controlli compatti (toggle in navbar). */ -export const PATH_LABEL_SHORT: Record = { - it: 'IT', - liceo: 'Liceo', - its: 'ITS', -}; - -export function pathLabel(id: string, short = false): string { - const map = short ? PATH_LABEL_SHORT : PATH_LABEL; - return map[id] ?? id; -} diff --git a/src/lib/curriculumCode.ts b/src/lib/curriculumCode.ts new file mode 100644 index 0000000..647ee5b --- /dev/null +++ b/src/lib/curriculumCode.ts @@ -0,0 +1,320 @@ +/** + * Codec dei codici-percorso (spec: pm/design-percorsi-personalizzati.md). + * + * Un codice è una funzione pura della selezione di lezioni: byte binari + * (versione, epoca, modo, N, payload, CRC-8) resi in Crockford Base32 con + * prefisso `PDB1-` e gruppi di 4. L'encoder produce il payload sia come + * bitmask sia come RLE (coppie varint salto/run) ed emette il più corto; + * il decoder accetta entrambi i modi, è case-insensitive, ignora trattini + * e spazi e mappa i caratteri ambigui (O→0, I/L→1). + * + * Nessuna dipendenza: usabile da client, plugin Node e script. + */ + +export const CODE_VERSION = 1; +export const CODE_PREFIX = 'PDB1'; + +export const MODE_BITMASK = 0x00; +export const MODE_RLE = 0x01; + +/** Motivi di rifiuto, per messaggi d'errore mirati nella UI. */ +export type CurriculumCodeErrorReason = + | 'empty' // stringa vuota dopo la normalizzazione + | 'charset' // caratteri fuori dall'alfabeto Base32 Crockford + | 'truncated' // troppo corto per contenere header + CRC + | 'crc' // checksum errato (typo o codice monco) + | 'version' // versione formato sconosciuta (sito più vecchio del codice) + | 'mode' // modo di codifica sconosciuto + | 'payload' // payload incoerente con N (bit oltre N, run fuori range) + | 'epoch'; // epoca assente dal manifest (lanciato dal CurriculumContext) + +export class CurriculumCodeError extends Error { + readonly reason: CurriculumCodeErrorReason; + + constructor(reason: CurriculumCodeErrorReason, message: string) { + super(message); + this.name = 'CurriculumCodeError'; + this.reason = reason; + } +} + +export interface DecodedCurriculum { + version: number; + /** Epoca del manifest contro cui il codice è stato creato (1 = a.s. 2026/27). */ + epoch: number; + /** Lunghezza del manifest d'epoca al momento della creazione (snapshot). */ + n: number; + /** Indici di bit inclusi (posizioni nel manifest d'epoca). */ + indices: ReadonlySet; +} + +// ── Base32 Crockford ──────────────────────────────────────────────────────── + +const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +const CHAR_VALUE: Record = {}; +for (let i = 0; i < ALPHABET.length; i++) CHAR_VALUE[ALPHABET[i]] = i; +// Mappature di cortesia per i caratteri esclusi dall'alfabeto. +CHAR_VALUE['O'] = 0; +CHAR_VALUE['I'] = 1; +CHAR_VALUE['L'] = 1; + +function base32Encode(bytes: Uint8Array): string { + let out = ''; + let acc = 0; + let bits = 0; + for (const byte of bytes) { + acc = (acc << 8) | byte; + bits += 8; + while (bits >= 5) { + out += ALPHABET[(acc >> (bits - 5)) & 0x1f]; + bits -= 5; + } + } + if (bits > 0) out += ALPHABET[(acc << (5 - bits)) & 0x1f]; + return out; +} + +function base32Decode(text: string): Uint8Array { + const out: number[] = []; + let acc = 0; + let bits = 0; + for (const ch of text) { + const value = CHAR_VALUE[ch]; + if (value === undefined) { + throw new CurriculumCodeError( + 'charset', + `Carattere non valido nel codice: «${ch}».`, + ); + } + acc = (acc << 5) | value; + bits += 5; + if (bits >= 8) { + out.push((acc >> (bits - 8)) & 0xff); + bits -= 8; + } + } + // I bit residui (< 8) sono padding dell'ultimo carattere: si scartano. + return Uint8Array.from(out); +} + +// ── CRC-8 (poly 0x07, init 0x00) ──────────────────────────────────────────── + +function crc8(bytes: Uint8Array, length: number): number { + let crc = 0; + for (let i = 0; i < length; i++) { + crc ^= bytes[i]; + for (let bit = 0; bit < 8; bit++) { + crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff; + } + } + return crc; +} + +// ── Varint LEB128 (unsigned) ──────────────────────────────────────────────── + +function pushVarint(out: number[], value: number): void { + let v = value; + while (v >= 0x80) { + out.push((v & 0x7f) | 0x80); + v >>>= 7; + } + out.push(v); +} + +function readVarint(bytes: Uint8Array, offset: number): [number, number] { + let value = 0; + let shift = 0; + let pos = offset; + for (;;) { + if (pos >= bytes.length) { + throw new CurriculumCodeError('payload', 'Payload RLE troncato.'); + } + const byte = bytes[pos++]; + value |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) return [value, pos]; + shift += 7; + if (shift > 28) { + throw new CurriculumCodeError('payload', 'Varint fuori range.'); + } + } +} + +// ── Payload: bitmask e RLE ────────────────────────────────────────────────── + +function encodeBitmask(n: number, indices: ReadonlySet): number[] { + const bytes = new Array(Math.ceil(n / 8)).fill(0); + for (const i of indices) { + bytes[i >> 3] |= 1 << (7 - (i & 7)); + } + return bytes; +} + +function decodeBitmask(payload: Uint8Array, n: number): Set { + const indices = new Set(); + if (payload.length !== Math.ceil(n / 8)) { + throw new CurriculumCodeError( + 'payload', + `Bitmask di ${payload.length} byte incoerente con N=${n}.`, + ); + } + for (let i = 0; i < n; i++) { + if (payload[i >> 3] & (1 << (7 - (i & 7)))) indices.add(i); + } + return indices; +} + +/** + * RLE: coppie varint (salto, run) — `salto` bit a 0 dalla posizione corrente, + * poi `run` (≥ 1) bit a 1. Le posizioni dopo l'ultima coppia sono a 0. + */ +function encodeRle(indices: ReadonlySet): number[] { + const sorted = [...indices].sort((a, b) => a - b); + const out: number[] = []; + let cursor = 0; + let i = 0; + while (i < sorted.length) { + const start = sorted[i]; + let end = i; + while (end + 1 < sorted.length && sorted[end + 1] === sorted[end] + 1) { + end++; + } + pushVarint(out, start - cursor); + pushVarint(out, end - i + 1); + cursor = sorted[end] + 1; + i = end + 1; + } + return out; +} + +function decodeRle(payload: Uint8Array, n: number): Set { + const indices = new Set(); + let cursor = 0; + let pos = 0; + while (pos < payload.length) { + const [gap, afterGap] = readVarint(payload, pos); + const [run, afterRun] = readVarint(payload, afterGap); + pos = afterRun; + if (run < 1 || cursor + gap + run > n) { + throw new CurriculumCodeError( + 'payload', + `Run RLE fuori range (cursor=${cursor}, gap=${gap}, run=${run}, N=${n}).`, + ); + } + cursor += gap; + for (let k = 0; k < run; k++) indices.add(cursor++); + } + return indices; +} + +// ── API pubblica ──────────────────────────────────────────────────────────── + +/** + * Codifica una selezione in codice testuale canonico (`PDB1-XXXX-…`). + * + * @param epoch epoca del manifest (1–255) + * @param n lunghezza del manifest d'epoca (numero di bit, 0–65535) + * @param indices posizioni incluse (0 ≤ i < n) + */ +export function encodeCurriculum( + epoch: number, + n: number, + indices: ReadonlySet, +): string { + if (!Number.isInteger(epoch) || epoch < 1 || epoch > 255) { + throw new RangeError(`Epoca fuori range (1–255): ${epoch}`); + } + if (!Number.isInteger(n) || n < 0 || n > 0xffff) { + throw new RangeError(`N fuori range (0–65535): ${n}`); + } + for (const i of indices) { + if (!Number.isInteger(i) || i < 0 || i >= n) { + throw new RangeError(`Indice fuori dal manifest (N=${n}): ${i}`); + } + } + + const bitmask = encodeBitmask(n, indices); + const rle = encodeRle(indices); + const [mode, payload] = + rle.length < bitmask.length ? [MODE_RLE, rle] : [MODE_BITMASK, bitmask]; + + const bytes = new Uint8Array(5 + payload.length + 1); + bytes[0] = CODE_VERSION; + bytes[1] = epoch; + bytes[2] = mode; + bytes[3] = (n >> 8) & 0xff; + bytes[4] = n & 0xff; + bytes.set(payload, 5); + bytes[bytes.length - 1] = crc8(bytes, bytes.length - 1); + + const text = base32Encode(bytes); + const groups = text.match(/.{1,4}/g) ?? []; + return [CODE_PREFIX, ...groups].join('-'); +} + +/** + * Decodifica un codice testuale. Tollera minuscole, spazi, trattini, i + * caratteri ambigui O/I/L e il prefisso mancante. Lancia + * `CurriculumCodeError` (con `reason`) su ogni input non valido. + */ +export function decodeCurriculum(raw: string): DecodedCurriculum { + // La mappatura di cortesia precede lo strip del prefisso, così anche un + // «PDBI-…» scritto a mano viene riconosciuto come «PDB1-…». + let text = raw + .replace(/[\s-]+/g, '') + .toUpperCase() + .replace(/O/g, '0') + .replace(/[IL]/g, '1'); + if (text.startsWith(CODE_PREFIX)) text = text.slice(CODE_PREFIX.length); + if (text.length === 0) { + throw new CurriculumCodeError('empty', 'Codice vuoto.'); + } + + const bytes = base32Decode(text); + // Header (5 byte) + CRC (1 byte): sotto questa soglia il codice è monco. + if (bytes.length < 6) { + throw new CurriculumCodeError('truncated', 'Codice troppo corto.'); + } + if (crc8(bytes, bytes.length - 1) !== bytes[bytes.length - 1]) { + throw new CurriculumCodeError( + 'crc', + 'Checksum errato: controlla di aver copiato il codice per intero.', + ); + } + if (bytes[0] !== CODE_VERSION) { + throw new CurriculumCodeError( + 'version', + `Versione formato sconosciuta (${bytes[0]}): il codice è stato creato ` + + 'con una versione più recente del sito.', + ); + } + + const epoch = bytes[1]; + const mode = bytes[2]; + const n = (bytes[3] << 8) | bytes[4]; + const payload = bytes.subarray(5, bytes.length - 1); + + let indices: Set; + if (mode === MODE_BITMASK) { + indices = decodeBitmask(payload, n); + } else if (mode === MODE_RLE) { + indices = decodeRle(payload, n); + } else { + throw new CurriculumCodeError( + 'mode', + `Modo di codifica sconosciuto (${mode}).`, + ); + } + + return { version: bytes[0], epoch, n, indices }; +} + +/** + * Forma canonica di un codice comunque scritto (minuscole, senza trattini, + * modo non ottimale…): decodifica e ricodifica. Il determinismo della forma + * canonica permette il confronto stringa con i codici dei preset. + */ +export function normalizeCurriculumCode(raw: string): string { + const { epoch, n, indices } = decodeCurriculum(raw); + return encodeCurriculum(epoch, n, indices); +} diff --git a/src/lib/curriculumFilter.ts b/src/lib/curriculumFilter.ts new file mode 100644 index 0000000..3c39c46 --- /dev/null +++ b/src/lib/curriculumFilter.ts @@ -0,0 +1,60 @@ +/** + * Filtri presentazionali per il percorso attivo: operano sulla PropSidebar + * già compilata da Docusaurus (quella che DocRoot passa al layout), quindi + * il filtro non tocca build, ricerca o URL — solo ciò che l'utente vede. + */ +import type { + PropSidebar, + PropSidebarItem, +} from '@docusaurus/plugin-content-docs'; + +/** + * Rimuove dalla sidebar i doc esclusi dal percorso e le categorie rimaste + * vuote. Le voci che non sono lezioni (link esterni, html) restano. + */ +export function filterSidebarItems( + items: readonly PropSidebarItem[], + volumeId: string, + isIncluded: (key: string) => boolean, +): PropSidebarItem[] { + const out: PropSidebarItem[] = []; + for (const item of items) { + if (item.type === 'link') { + if (item.docId && !isIncluded(`${volumeId}/${item.docId}`)) continue; + out.push(item); + } else if (item.type === 'category') { + const children = filterSidebarItems(item.items, volumeId, isIncluded); + if (children.length === 0) continue; + out.push({ ...item, items: children }); + } else { + out.push(item); + } + } + return out; +} + +export interface SidebarDocLink { + href: string; + label: string; +} + +/** + * Appiattisce una sidebar (già filtrata) nella sequenza ordinata delle sue + * pagine navigabili — doc link e landing page delle categorie (categoria + * prima dei figli, come la paginazione nativa di Docusaurus). Serve a + * ricalcolare prev/next quando un percorso è attivo. + */ +export function flattenSidebarLinks( + items: PropSidebar | readonly PropSidebarItem[], + out: SidebarDocLink[] = [], +): SidebarDocLink[] { + for (const item of items as readonly PropSidebarItem[]) { + if (item.type === 'link') { + out.push({ href: item.href, label: item.label }); + } else if (item.type === 'category') { + if (item.href) out.push({ href: item.href, label: item.label }); + flattenSidebarLinks(item.items, out); + } + } + return out; +} diff --git a/src/lib/curriculumSelection.ts b/src/lib/curriculumSelection.ts new file mode 100644 index 0000000..3ee14b9 --- /dev/null +++ b/src/lib/curriculumSelection.ts @@ -0,0 +1,55 @@ +/** + * Ponte tra selezione di lezioni (chiavi «/») e codice + * testuale, contro il manifest a epoche. Condiviso da CurriculumContext, + * pagina /percorso e CurriculumIndicator. + */ +import { + CurriculumCodeError, + decodeCurriculum, + encodeCurriculum, +} from './curriculumCode'; + +export interface EpochManifest { + version: number; + currentEpoch: number; + epochs: Record; +} + +/** Codifica una selezione di chiavi contro l'epoca corrente del manifest. */ +export function codeFromKeys( + keys: ReadonlySet, + manifest: EpochManifest, +): string { + const entries = manifest.epochs[String(manifest.currentEpoch)]; + const indices = new Set(); + entries.forEach((key, i) => { + if (keys.has(key)) indices.add(i); + }); + return encodeCurriculum(manifest.currentEpoch, entries.length, indices); +} + +/** + * Decodifica un codice nelle chiavi incluse, contro il manifest della SUA + * epoca. Indici oltre la lunghezza attuale (codice più nuovo del sito) o + * slot tombstone: bit inerti, ignorati. Epoca sconosciuta → + * CurriculumCodeError('epoch'). + */ +export function keysFromCode( + raw: string, + manifest: EpochManifest, +): ReadonlySet { + const { epoch, indices } = decodeCurriculum(raw); + const entries = manifest.epochs[String(epoch)]; + if (!entries) { + throw new CurriculumCodeError( + 'epoch', + `Il codice appartiene a un'epoca (${epoch}) che questo sito non ` + + 'conosce ancora: ricarica la pagina per aggiornare il sito.', + ); + } + const keys = new Set(); + for (const i of indices) { + if (i < entries.length) keys.add(entries[i]); + } + return keys; +} diff --git a/src/lib/docResolve.ts b/src/lib/docResolve.ts index b0723e2..e9bf71e 100644 --- a/src/lib/docResolve.ts +++ b/src/lib/docResolve.ts @@ -4,7 +4,7 @@ * * Il permalink NON va ricostruito a mano (regole slug/index/trailingSlash sono * fragili): lo risolviamo dai dati che Docusaurus ha già calcolato, esposti via - * `useAllDocsData()` (vedi src/components/PathSelector per il pattern `current`). + * `useAllDocsData()` (versione `current` di ogni istanza plugin-content-docs). */ import type { GlobalPluginData } from '@docusaurus/plugin-content-docs/client'; diff --git a/src/pages/percorso.module.css b/src/pages/percorso.module.css new file mode 100644 index 0000000..0c793f6 --- /dev/null +++ b/src/pages/percorso.module.css @@ -0,0 +1,554 @@ +.page { + max-width: 1080px; + margin: 0 auto; + padding: 48px 24px 96px; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.header { + max-width: 640px; + margin-bottom: 40px; +} + +.kicker { + font-family: var(--font-mono-ui); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--at-accent); + margin: 0 0 10px; +} + +.title { + font-family: var(--font-display); + font-size: clamp(2.1rem, 5vw, 3rem); + line-height: 1.08; + margin: 0 0 14px; + background: var(--at-grad-text); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.lede { + font-size: 1.08rem; + line-height: 1.6; + color: var(--at-muted-soft); + margin: 0; +} + +/* ── Card studente ──────────────────────────────────────────────────────── */ + +.studentCard { + background: var(--at-bg-panel); + border: 1px solid var(--at-border); + border-radius: var(--radius-lg); + padding: 20px 22px; + margin-bottom: 56px; + box-shadow: var(--shadow-card); +} + +.studentTitle { + font-family: var(--font-display); + font-size: 1.2rem; + font-weight: 600; + color: var(--at-fg-strong); + margin: 0 0 12px; +} + +.studentForm { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.codeInput { + flex: 1; + min-width: 220px; + font-family: var(--font-mono); + font-size: 0.95rem; + letter-spacing: 0.06em; + padding: 10px 14px; + color: var(--at-fg); + background: var(--at-bg-subtle); + border: 1px solid var(--at-border-strong); + border-radius: var(--radius-sm); + transition: border-color var(--dur-fast) var(--ease-smooth); +} + +.codeInput:focus { + outline: none; + border-color: var(--at-accent); +} + +.codeInput::placeholder { + color: var(--at-faint); +} + +.studentActive { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.activeIcon { + font-size: 22px; + color: var(--at-accent); + flex-shrink: 0; +} + +.studentActiveBody { + flex: 1; + min-width: 220px; +} + +.studentActiveBody .studentTitle { + margin-bottom: 6px; +} + +.studentActions { + display: inline-flex; + align-items: center; + gap: 12px; +} + +/* ── Targhetta codice ───────────────────────────────────────────────────── */ + +.codePlate, +.codePlateLive { + font-family: var(--font-mono); + letter-spacing: 0.08em; + overflow-wrap: anywhere; + color: var(--at-fg-strong); + background: var(--at-bg-subtle); + border: 1px solid var(--at-border); + border-radius: var(--radius-sm); + margin: 0; +} + +.codePlate { + display: inline-block; + font-size: 0.95rem; + padding: 6px 12px; +} + +.codePlateLive { + font-size: 1.15rem; + line-height: 1.5; + padding: 14px 16px; +} + +/* ── Sezione docente ────────────────────────────────────────────────────── */ + +.teacherHead { + max-width: 640px; +} + +.teacherTitle { + font-family: var(--font-display); + font-size: 1.7rem; + margin: 0 0 8px; + color: var(--at-fg-strong); +} + +.teacherLede { + font-size: 1rem; + line-height: 1.6; + color: var(--at-muted-soft); + margin: 0 0 16px; +} + +.presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 28px; +} + +.presetBtn { + appearance: none; + cursor: pointer; + font-family: var(--font-mono-ui); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.03em; + padding: 6px 14px; + color: var(--at-muted); + background: var(--at-bg-chip); + border: 1px solid var(--at-border); + border-radius: var(--radius-pill); + transition: + color var(--dur-fast) var(--ease-smooth), + background var(--dur-fast) var(--ease-smooth), + border-color var(--dur-fast) var(--ease-smooth); +} + +.presetBtn:hover { + color: var(--at-fg-strong); + border-color: var(--at-border-strong); +} + +.presetBtn:active { + transform: scale(0.98); +} + +.presetActive { + color: var(--at-accent-soft); + font-weight: 600; + background: var(--at-accent-chip); + border-color: var(--at-accent-chip-border); +} + +/* ── Workbench: albero + pannello ───────────────────────────────────────── */ + +.workbench { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 32px; + align-items: start; +} + +.volumes { + display: flex; + flex-direction: column; + gap: 20px; +} + +.volume { + background: var(--at-bg-panel); + border: 1px solid var(--at-border); + border-radius: var(--radius-md); + padding: 16px 18px; +} + +.volumeLabel { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + margin-bottom: 6px; +} + +.volumeName { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--at-fg-strong); +} + +.tree { + list-style: none; + margin: 0; + padding-left: 26px; +} + +.branch { + margin: 6px 0; +} + +.branchLabel { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-weight: 600; + color: var(--at-fg); +} + +.leaf { + margin: 4px 0; +} + +.leafLabel { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 0.98rem; + color: var(--at-fg-body); +} + +.leafForced { + color: var(--at-muted); +} + +.forcedNote { + font-family: var(--font-mono-ui); + font-size: 0.72em; + letter-spacing: 0.05em; + color: var(--at-faint); +} + +.checkbox { + width: 16px; + height: 16px; + flex-shrink: 0; + accent-color: var(--at-accent); + cursor: pointer; +} + +.checkbox:disabled { + cursor: default; +} + +.checkbox:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +/* ── Pannello codice (sticky) ───────────────────────────────────────────── */ + +.codePanel { + position: sticky; + top: calc(var(--ifm-navbar-height) + 24px); + background: var(--at-bg-panel); + border: 1px solid var(--at-border); + border-radius: var(--radius-lg); + padding: 18px 20px; + box-shadow: var(--shadow-card); +} + +.codeCount { + font-family: var(--font-mono-ui); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--at-muted); + margin: 0 0 10px; +} + +.codePreset { + color: var(--at-accent-soft); +} + +.codeActions { + display: flex; + gap: 10px; + margin-top: 14px; + flex-wrap: wrap; +} + +.codeHint { + font-size: 0.88rem; + line-height: 1.5; + color: var(--at-muted); + margin: 14px 0 0; +} + +/* ── Bottoni ────────────────────────────────────────────────────────────── */ + +.primaryBtn { + appearance: none; + cursor: pointer; + font-family: var(--font-mono-ui); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.02em; + padding: 9px 18px; + color: #fff; + background: var(--at-accent); + border: 1px solid transparent; + border-radius: var(--radius-pill); + transition: + background var(--dur-fast) var(--ease-smooth), + transform var(--dur-fast) var(--ease-smooth); +} + +.primaryBtn:hover { + background: var(--at-accent-soft); + color: #fff; + text-decoration: none; +} + +.primaryBtn:active { + transform: scale(0.98); +} + +.primaryBtn:disabled { + opacity: 0.5; + cursor: default; +} + +.primaryBtn:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +html[data-theme='dark'] .primaryBtn { + color: #09090b; +} + +html[data-theme='dark'] .primaryBtn:hover { + color: #09090b; +} + +.ghostBtn { + appearance: none; + cursor: pointer; + font: inherit; + font-family: var(--font-mono-ui); + font-size: 14px; + letter-spacing: 0.02em; + padding: 9px 16px; + color: var(--at-muted-soft); + background: none; + border: 1px solid var(--at-border-strong); + border-radius: var(--radius-pill); + transition: + color var(--dur-fast) var(--ease-smooth), + border-color var(--dur-fast) var(--ease-smooth); +} + +.ghostBtn:hover { + color: var(--at-fg-strong); + border-color: var(--at-faint); +} + +.ghostBtn:active { + transform: scale(0.98); +} + +.ghostBtn:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +.btnIcon { + margin-right: 7px; + font-size: 0.9em; +} + +/* ── Overlay QR (LIM) ───────────────────────────────────────────────────── */ + +.qrOverlay { + position: fixed; + inset: 0; + z-index: calc(var(--ifm-z-index-fixed) + 10); + display: flex; + align-items: center; + justify-content: center; + background: rgba(9, 9, 11, 0.82); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + animation: qrFadeIn var(--dur-normal) var(--ease-out); +} + +/* La targa è volutamente bianca in entrambi i temi: il QR proiettato ha + bisogno del massimo contrasto e di una quiet zone chiara. */ +.qrPlate { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + background: #fff; + border-radius: var(--radius-lg); + padding: 24px 28px 20px; + box-shadow: var(--shadow-elevated); + animation: qrPopIn var(--dur-slow) var(--ease-spring); +} + +.qrSvg { + width: min(62vmin, 520px); + height: min(62vmin, 520px); +} + +.qrSvg svg { + display: block; + width: 100%; + height: 100%; +} + +.qrCodeText { + font-family: var(--font-mono); + font-size: clamp(0.9rem, 2.4vmin, 1.4rem); + letter-spacing: 0.08em; + color: #09090b; + margin: 0; +} + +.qrClose { + position: absolute; + top: 20px; + right: 24px; + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--radius-circle); + color: #fafafa; + font-size: 18px; + cursor: pointer; + transition: background var(--dur-fast) var(--ease-smooth); +} + +.qrClose:hover { + background: rgba(255, 255, 255, 0.16); +} + +.qrClose:focus-visible { + outline: 2px solid #fafafa; + outline-offset: 2px; +} + +@keyframes qrFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes qrPopIn { + from { + opacity: 0; + transform: scale(0.94); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .qrOverlay, + .qrPlate { + animation: none; + } +} + +/* ── Errori ─────────────────────────────────────────────────────────────── */ + +.error { + font-size: 0.92rem; + color: #b91c1c; + margin: 10px 0 0; +} + +html[data-theme='dark'] .error { + color: #fca5a5; +} + +/* ── Responsive ─────────────────────────────────────────────────────────── */ + +@media (max-width: 900px) { + .page { + padding: 32px 16px 64px; + } + + .workbench { + grid-template-columns: 1fr; + } + + .codePanel { + position: static; + order: -1; + } +} diff --git a/src/pages/percorso.tsx b/src/pages/percorso.tsx new file mode 100644 index 0000000..0bd47d0 --- /dev/null +++ b/src/pages/percorso.tsx @@ -0,0 +1,613 @@ +/** + * /percorso — configuratore dei percorsi personalizzati. + * + * Doppio ruolo sulla stessa pagina: + * - Studente: incolla il codice ricevuto dal docente e lo attiva. + * - Docente: costruisce la selezione (volume → capitolo tri-state → + * lezione), parte da un preset o dal libro intero, e ottiene il codice + * deterministico da distribuire (targhetta live + copia codice/link). + * + * La selezione codifica solo lezioni: le pagine sempre visibili (intro dei + * volumi) appaiono spuntate e disabilitate. Spec e semantica: + * pm/design-percorsi-personalizzati.md. + */ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import Layout from '@theme/Layout'; +import Heading from '@theme/Heading'; +import Link from '@docusaurus/Link'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import copy from 'copy-text-to-clipboard'; +import QRCode from 'qrcode'; + +import { + useCurriculum, + useCurriculumPluginData, + type CurriculumTreeItem, +} from '@site/src/contexts/CurriculumContext'; +import { CurriculumCodeError } from '@site/src/lib/curriculumCode'; +import { codeFromKeys, keysFromCode } from '@site/src/lib/curriculumSelection'; + +import styles from './percorso.module.css'; + +// ── Helper puri ───────────────────────────────────────────────────────────── + +function collectKeys( + items: CurriculumTreeItem[], + out: string[] = [], +): string[] { + for (const item of items) { + if (item.type === 'doc') out.push(item.key); + else collectKeys(item.items, out); + } + return out; +} + +function copyText(text: string): void { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).catch(() => copy(text)); + } else { + copy(text); + } +} + +function errorMessage(e: unknown): string { + if (e instanceof CurriculumCodeError) { + switch (e.reason) { + case 'crc': + return 'Codice non valido: controlla di averlo copiato per intero.'; + case 'version': + case 'epoch': + return 'Il codice arriva da una versione più nuova del sito: ricarica la pagina e riprova.'; + default: + return 'Questo non sembra un codice percorso.'; + } + } + return 'Codice non riconosciuto.'; +} + +// ── Checkbox tri-state ────────────────────────────────────────────────────── + +function TriCheckbox({ + checked, + indeterminate, + disabled, + onChange, + ariaLabel, +}: { + checked: boolean; + indeterminate?: boolean; + disabled?: boolean; + onChange: (next: boolean) => void; + ariaLabel: string; +}) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.indeterminate = Boolean(indeterminate); + }, [indeterminate]); + return ( + onChange(e.target.checked)} + /> + ); +} + +// ── Albero del configuratore ──────────────────────────────────────────────── + +function TreeNodes({ + items, + selection, + alwaysVisible, + onToggleDoc, + onToggleGroup, +}: { + items: CurriculumTreeItem[]; + selection: ReadonlySet; + alwaysVisible: ReadonlySet; + onToggleDoc: (key: string, next: boolean) => void; + onToggleGroup: (keys: string[], next: boolean) => void; +}) { + return ( +
    + {items.map((item) => { + if (item.type === 'doc') { + const forced = alwaysVisible.has(item.key); + return ( +
  • + +
  • + ); + } + const keys = collectKeys(item.items).filter( + (k) => !alwaysVisible.has(k), + ); + const chosen = keys.filter((k) => selection.has(k)).length; + return ( +
  • + + +
  • + ); + })} +
+ ); +} + +// ── Overlay QR per la LIM ─────────────────────────────────────────────────── + +/** + * QR a tutto schermo del link condiviso, pensato per essere proiettato: + * gli studenti inquadrano il QR (o copiano il codice scritto sotto). + * Chiusura con click fuori dalla targa, «×» o Esc. + */ +function QrOverlay({ + link, + code, + onClose, +}: { + link: string; + code: string; + onClose: () => void; +}) { + const [svg, setSvg] = useState(null); + + useEffect(() => { + let alive = true; + QRCode.toString(link, { + type: 'svg', + errorCorrectionLevel: 'M', + margin: 2, + }).then((s) => { + if (alive) setSvg(s); + }); + return () => { + alive = false; + }; + }, [link]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + }; + }, [onClose]); + + return ( +
+
e.stopPropagation()} + role="presentation" + > + {svg && ( +
+ )} +

+ {code} +

+
+ +
+ ); +} + +// ── Sezione studente ──────────────────────────────────────────────────────── + +function StudentCard({ + presetLabelOf, +}: { + presetLabelOf: (code: string) => string | null; +}) { + const { code, setCode, clear } = useCurriculum(); + const [draft, setDraft] = useState(''); + const [error, setError] = useState(null); + const startUrl = useBaseUrl('/programmatore'); + + const activate = () => { + try { + setCode(draft); + setDraft(''); + setError(null); + } catch (e) { + setError(errorMessage(e)); + } + }; + + if (code) { + const presetLabel = presetLabelOf(code); + return ( +
+
+
+
+ ); + } + + return ( +
+

Hai ricevuto un codice dal docente?

+
{ + e.preventDefault(); + activate(); + }} + > + { + setDraft(e.target.value); + setError(null); + }} + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} + +// ── Pagina ────────────────────────────────────────────────────────────────── + +export default function PercorsoPage(): ReactNode { + const data = useCurriculumPluginData(); + const { code } = useCurriculum(); + const { siteConfig } = useDocusaurusContext(); + const percorsoUrl = useBaseUrl('/percorso'); + + const allKeys = useMemo( + () => data.volumes.flatMap((v) => collectKeys(v.tree)), + [data], + ); + const alwaysVisibleSet = useMemo(() => new Set(data.alwaysVisible), [data]); + + // Selezione del docente: parte dal libro intero. Le sempre-visibili sono + // tenute DENTRO la selezione (il codice canonico dei preset le include). + const [selection, setSelection] = useState>( + () => new Set(allKeys), + ); + const [copied, setCopied] = useState<'code' | 'link' | null>(null); + const [showQr, setShowQr] = useState(false); + const [importError, setImportError] = useState(null); + const copiedTimer = useRef>(); + + const liveCode = useMemo( + () => codeFromKeys(selection, data.manifest), + [selection, data], + ); + + // Codici canonici dei preset (deterministici → confronto stringa). + const presetCodes = useMemo( + () => + data.presets.map((p) => ({ + ...p, + code: codeFromKeys(new Set(p.keys), data.manifest), + })), + [data], + ); + const presetLabelOf = useCallback( + (c: string) => presetCodes.find((p) => p.code === c)?.label ?? null, + [presetCodes], + ); + + const shareLink = `${siteConfig.url}${percorsoUrl}?percorso=${liveCode}`; + const lessonCount = allKeys.filter( + (k) => selection.has(k) && !alwaysVisibleSet.has(k), + ).length; + const lessonTotal = allKeys.filter((k) => !alwaysVisibleSet.has(k)).length; + + const toggleDoc = useCallback((key: string, next: boolean) => { + setSelection((prev) => { + const out = new Set(prev); + if (next) out.add(key); + else out.delete(key); + return out; + }); + }, []); + + const toggleGroup = useCallback((keys: string[], next: boolean) => { + setSelection((prev) => { + const out = new Set(prev); + for (const k of keys) { + if (next) out.add(k); + else out.delete(k); + } + return out; + }); + }, []); + + const applyImport = useCallback( + (raw: string) => { + try { + const imported = keysFromCode(raw, data.manifest); + setSelection(new Set([...imported, ...data.alwaysVisible])); + setImportError(null); + } catch (e) { + setImportError(errorMessage(e)); + } + }, + [data], + ); + + const markCopied = (what: 'code' | 'link') => { + setCopied(what); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(null), 2000); + }; + + return ( + +
+
+

Percorsi personalizzati

+ + Il libro, su misura + +

+ Il docente sceglie le lezioni e ottiene un codice. Chi lo inserisce + vede il libro esattamente come è stato pensato per la sua classe — + tutto il resto rimane a portata di link. +

+
+ + + +
+
+ + Costruisci un percorso + +

+ Parti da un preset o dal libro intero, poi togli e aggiungi + lezioni. Il codice qui accanto si aggiorna da solo. +

+
+ {presetCodes.map((p) => ( + + ))} + + {code && code !== liveCode && ( + + )} +
+ {importError && ( +

+ {importError} +

+ )} +
+ +
+
+ {data.volumes.map((volume) => { + const keys = collectKeys(volume.tree).filter( + (k) => !alwaysVisibleSet.has(k), + ); + const chosen = keys.filter((k) => selection.has(k)).length; + return ( +
+ + +
+ ); + })} +
+ + +
+
+
+ {showQr && ( + setShowQr(false)} + /> + )} +
+ ); +} diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx index 448f9d4..dfa9ffb 100644 --- a/src/theme/DocItem/Content/index.tsx +++ b/src/theme/DocItem/Content/index.tsx @@ -10,7 +10,7 @@ import type { PropSidebarItem, } from '@docusaurus/plugin-content-docs'; -import OffPathBanner from '@site/src/components/OffPathBanner'; +import OffCurriculumBanner from '@site/src/components/OffCurriculumBanner'; import { LessonMeta } from '@site/src/components/Exercise'; function useSyntheticTitle(): string | null { @@ -81,7 +81,7 @@ export default function DocItemContent({ children }: Props): ReactNode { kicker && 'doc-has-chapter-kicker', )} > - + {kicker &&

{kicker}

} {syntheticTitle && (
diff --git a/src/theme/DocPaginator/index.tsx b/src/theme/DocPaginator/index.tsx new file mode 100644 index 0000000..2bfd888 --- /dev/null +++ b/src/theme/DocPaginator/index.tsx @@ -0,0 +1,57 @@ +/** + * Swizzle-wrapper di DocPaginator — con un percorso attivo ricalcola + * prev/next dalla sidebar filtrata (useDocsSidebar è già l'albero filtrato + * grazie al DocRoot swizzlato), così la paginazione salta le lezioni + * escluse. Senza percorso: passthrough puro. + * + * Se la pagina corrente non compare nella sidebar filtrata (lezione esclusa + * aperta via link diretto) non renderizza nulla: il contesto lo spiega + * OffCurriculumBanner in cima alla pagina. + */ +import React, { type ReactNode } from 'react'; +import DocPaginator from '@theme-original/DocPaginator'; +import type { Props } from '@theme/DocPaginator'; +import { + useDocsSidebar, + useDocsVersion, +} from '@docusaurus/plugin-content-docs/client'; +import { useLocation } from '@docusaurus/router'; + +import { + useCurriculum, + useCurriculumPluginData, +} from '@site/src/contexts/CurriculumContext'; +import { flattenSidebarLinks } from '@site/src/lib/curriculumFilter'; + +function normalizePath(p: string): string { + return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p; +} + +export default function DocPaginatorWrapper(props: Props): ReactNode { + const { included } = useCurriculum(); + const { volumes } = useCurriculumPluginData(); + const version = useDocsVersion(); + const sidebar = useDocsSidebar(); + const location = useLocation(); + + const isVolume = volumes.some((v) => v.id === version.pluginId); + if (!included || !isVolume || !sidebar) { + return ; + } + + const links = flattenSidebarLinks(sidebar.items); + const current = normalizePath(location.pathname); + const index = links.findIndex((l) => normalizePath(l.href) === current); + if (index === -1) return null; // pagina fuori percorso: niente prev/next + + const toNav = (l?: { href: string; label: string }) => + l ? { permalink: l.href, title: l.label } : undefined; + + return ( + + ); +} diff --git a/src/theme/DocRoot/index.tsx b/src/theme/DocRoot/index.tsx index 8828fc3..8ed4754 100644 --- a/src/theme/DocRoot/index.tsx +++ b/src/theme/DocRoot/index.tsx @@ -1,17 +1,15 @@ /** - * Swizzle DocRoot — sostituisce la sidebar default scelta da Docusaurus - * con quella corrispondente al "percorso" attualmente scelto dall'utente - * per il volume corrente, leggendo da PathContext. + * Swizzle DocRoot — con un percorso attivo (CurriculumContext) filtra la + * sidebar del volume corrente: le lezioni escluse spariscono, le categorie + * svuotate pure. Il filtro è presentazionale e SSR-safe per costruzione: + * al primo render `included` è null (libro intero), il percorso arriva + * dall'effect di idratazione. * - * Se l'utente non ha scelto nulla, oppure la sidebar selezionata non esiste - * per questo volume, ricade sul default (`sidebarName` ricavato dal doc). - * - * Quando la lezione corrente NON è dentro la sidebar attiva (es. utente sul - * percorso "Liceo" ma è arrivato a una lezione che vive solo in "IT"), - * mostriamo comunque la sidebar attiva: la lezione si rende a tutto schermo - * senza essere evidenziata in sidebar (banner off-path: prossimo step). + * Quando la lezione corrente NON è nel percorso (link diretto), la sidebar + * filtrata resta comunque: la lezione si rende senza evidenza in sidebar e + * OffCurriculumBanner spiega il perché. */ -import React, { type ReactNode } from 'react'; +import React, { useMemo, type ReactNode } from 'react'; import clsx from 'clsx'; import { HtmlClassNameProvider, @@ -27,30 +25,27 @@ import NotFoundContent from '@theme/NotFound/Content'; import type { Props } from '@theme/DocRoot'; import type { PropSidebar } from '@docusaurus/plugin-content-docs'; -import { usePathContext, type VolumeId } from '@site/src/contexts/PathContext'; - -const VOLUMES: ReadonlySet = new Set([ - 'programmatore', - 'artefice', - 'archivista', - 'apprendista', -]); +import { + useCurriculum, + useCurriculumPluginData, +} from '@site/src/contexts/CurriculumContext'; +import { filterSidebarItems } from '@site/src/lib/curriculumFilter'; -function useResolvedSidebar(defaultName: string | undefined) { +function useFilteredSidebar( + defaultItems: PropSidebar | undefined, +): PropSidebar | null { const version = useDocsVersion(); - const { getPath } = usePathContext(); + const { included, isIncluded } = useCurriculum(); + const { volumes } = useCurriculumPluginData(); const pluginId = version.pluginId; - if (!VOLUMES.has(pluginId)) { - // Pagine docs non-volume: comportamento standard. - return null; - } - const chosen = getPath(pluginId as VolumeId); - if (!chosen) return null; - const sidebars = version.docsSidebars; - if (!sidebars || !(chosen in sidebars)) return null; - if (chosen === defaultName) return null; - return { name: chosen, items: sidebars[chosen]! }; + return useMemo(() => { + // Nessun percorso attivo, pagina docs non-volume o sidebar assente: + // comportamento standard. + if (!included || !defaultItems) return null; + if (!volumes.some((v) => v.id === pluginId)) return null; + return filterSidebarItems(defaultItems, pluginId, isIncluded); + }, [included, isIncluded, volumes, pluginId, defaultItems]); } export default function DocRoot(props: Props): ReactNode { @@ -79,11 +74,9 @@ function DocRootInner({ defaultItems: PropSidebar | undefined; docElement: ReactNode; }) { - const resolved = useResolvedSidebar(defaultName); - const name = resolved?.name ?? defaultName; - const items = resolved?.items ?? defaultItems; + const filtered = useFilteredSidebar(defaultItems); return ( - + {docElement} ); diff --git a/src/theme/Navbar/Content/index.tsx b/src/theme/Navbar/Content/index.tsx index 1c13e0d..227b1a2 100644 --- a/src/theme/Navbar/Content/index.tsx +++ b/src/theme/Navbar/Content/index.tsx @@ -1,9 +1,8 @@ /** * Swizzle Navbar/Content — copia dell'originale theme-classic con l'aggiunta - * del nostro al centro fra gli item di sinistra e quelli - * di destra. Il componente PathSelector si nasconde da sé quando l'utente - * non è dentro un volume, quindi sulle altre pagine la navbar resta vuota - * al centro. + * del nostro al centro fra gli item di sinistra e + * quelli di destra. Il componente si nasconde da sé quando nessun percorso + * è attivo, quindi di norma la navbar resta vuota al centro. */ import React, { type ReactNode } from 'react'; import clsx from 'clsx'; @@ -23,7 +22,7 @@ import NavbarMobileSidebarToggle from '@theme/Navbar/MobileSidebar/Toggle'; import NavbarLogo from '@theme/Navbar/Logo'; import NavbarSearch from '@theme/Navbar/Search'; -import PathSelector from '@site/src/components/PathSelector'; +import CurriculumIndicator from '@site/src/components/CurriculumIndicator'; import NavbarIconButton from '@site/src/components/NavbarIconButton'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import MugSaucer from '@site/src/icons/mug-saucer.svg'; @@ -105,7 +104,7 @@ export default function NavbarContent(): ReactNode { } - center={} + center={} right={ <> diff --git a/src/theme/Root.tsx b/src/theme/Root.tsx index be21822..7beaae3 100644 --- a/src/theme/Root.tsx +++ b/src/theme/Root.tsx @@ -3,18 +3,18 @@ * - register the FontAwesome icon library globally (so navbar * icons, ColorModeToggle icons and other non-MDX surfaces * can use by name) - * - inject our PathProvider so any component (sidebar, banner, - * navbar selector) can read/write the user's chosen path per - * volume. + * - inject our CurriculumProvider so any component (sidebar filtrata, + * banner, indicatore navbar, pagina /percorso) can read/write the + * active curriculum code. */ import React, { type ReactNode } from 'react'; import { library } from '@fortawesome/fontawesome-svg-core'; import { fab } from '@fortawesome/free-brands-svg-icons'; import { fas } from '@fortawesome/free-solid-svg-icons'; -import { PathProvider } from '@site/src/contexts/PathContext'; +import { CurriculumProvider } from '@site/src/contexts/CurriculumContext'; library.add(fab, fas); export default function Root({ children }: { children: ReactNode }) { - return {children}; + return {children}; }