Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export default async function createConfig(): Promise<Config> {

plugins: [
'./plugins/pyrunner/index.js',
'./plugins/exercise-graph/index.js',
[
'@docusaurus/plugin-content-docs',
{
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "python-doesnt-byte",
"version": "0.7.13",
"version": "0.8.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
Expand Down
217 changes: 217 additions & 0 deletions plugins/exercise-graph/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
const path = require('path');
const fs = require('fs');
const {
parseMarkdownFile,
DEFAULT_PARSE_FRONT_MATTER,
} = require('@docusaurus/utils');

const PLUGIN_NAME = 'exercise-graph';

// Tutti i volumi (istanze plugin-content-docs). Le lezioni-sorgente vivono nei
// primi tre; gli esercizi nel quarto (apprendista).
const VOLUMES = ['programmatore', 'artefice', 'archivista', 'apprendista'];

const MD_EXT = /\.mdx?$/;

// Umanizza un id-segmento come fallback per il titolo quando manca il
// frontmatter (`title`/`sidebar_label`) e l'H1: «le-stringhe» → «Le stringhe».
function humanize(segment) {
const s = segment.replace(/[-_]+/g, ' ').trim();
return s.charAt(0).toUpperCase() + s.slice(1);
}

// Cammina ricorsivamente una cartella-volume e ritorna la lista dei file
// markdown con il loro docId volume-local (path relativo senza estensione,
// separatori posix). Replica la convenzione di id di @docusaurus/plugin-content-docs.
function walkDocs(dir, baseDir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
// Esclude file/cartelle con prefisso `_` o `.` — stessa convenzione di
// content-docs (es. _category_.json, _esercizio-template.mdx, partial), che
// li tiene fuori dal build: il grafo deve ignorarli per non validare un
// template-segnaposto.
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;
}

// docId effettivo: il frontmatter `id` sovrascrive l'ultimo segmento del path
// (stessa regola di content-docs). Senza override → relId.
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,
});
}

// Normalizza una chiave globale «<volume>/<docId>» in { volume, docId }.
// Lo split è sul PRIMO `/`: il volume è il primo segmento, il resto è il docId
// volume-local (che può contenere altri `/`).
function splitKey(raw, filePath, field) {
if (typeof raw !== 'string' || !raw.includes('/')) {
throw new Error(
`[exercise-graph] ${field} non valido in ${filePath}: ` +
`atteso «<volume>/<docId>», ricevuto ${JSON.stringify(raw)}.`,
);
}
const slash = raw.indexOf('/');
const volume = raw.slice(0, slash);
const docId = raw.slice(slash + 1);
if (!VOLUMES.includes(volume)) {
throw new Error(
`[exercise-graph] ${field} in ${filePath}: volume sconosciuto «${volume}». ` +
`Volumi validi: ${VOLUMES.join(', ')}.`,
);
}
return { volume, docId, key: `${volume}/${docId}` };
}

module.exports = function exerciseGraphPlugin(context) {
const volumeDir = (v) => path.join(context.siteDir, 'volumes', v);

return {
name: PLUGIN_NAME,

async loadContent() {
// ── Indice delle lezioni (tutti i volumi) ────────────────────────────
// lessons["<volume>/<docId>"] = { title } — usato per validare i
// riferimenti e per il display lato client.
const lessons = {};
// exercises["<docId>"] = record completo (la chiave è il docId
// volume-local dell'esercizio; il volume è sempre `apprendista`).
const exercises = {};
// byLesson["<volume>/<docId>"] = [ref...] — mappa inversa lezione→esercizi.
const byLesson = {};

// Raccolgo prima TUTTI i doc di tutti i volumi, così la validazione degli
// esercizi vede l'indice completo delle lezioni.
const docsByVolume = {};
await Promise.all(
VOLUMES.map(async (volume) => {
const base = volumeDir(volume);
const files = walkDocs(base, base);
const parsed = await Promise.all(
files.map(async ({ filePath, relId }) => {
const { frontMatter, contentTitle } = await parseDoc(filePath);
const docId = resolveDocId(relId, frontMatter);
const title =
(typeof frontMatter.title === 'string' && frontMatter.title) ||
(typeof frontMatter.sidebar_label === 'string' &&
frontMatter.sidebar_label) ||
contentTitle ||
humanize(docId.split('/').pop());
const key = `${volume}/${docId}`;
lessons[key] = { title };
return { filePath, docId, key, frontMatter, title };
}),
);
docsByVolume[volume] = parsed;
}),
);

// ── Esercizi (solo `apprendista`, marcati da `assigned_in`) ───────────
for (const doc of docsByVolume.apprendista) {
const { filePath, docId, frontMatter, title } = doc;
if (typeof frontMatter.assigned_in === 'undefined') continue; // non è un esercizio

const assignedIn = splitKey(
frontMatter.assigned_in,
filePath,
'assigned_in',
);
if (!lessons[assignedIn.key]) {
throw new Error(
`[exercise-graph] ${filePath}: assigned_in «${assignedIn.key}» ` +
`non corrisponde ad alcuna lezione esistente.`,
);
}

const theoryRaw = Array.isArray(frontMatter.theory)
? frontMatter.theory
: [];
const theory = theoryRaw.map((t) => {
const ref = splitKey(t, filePath, 'theory');
if (!lessons[ref.key]) {
throw new Error(
`[exercise-graph] ${filePath}: theory «${ref.key}» ` +
`non corrisponde ad alcuna lezione esistente.`,
);
}
return { volume: ref.volume, docId: ref.docId, title: lessons[ref.key].title };
});

const record = {
docId,
title,
kind:
typeof frontMatter.exercise_kind === 'string'
? frontMatter.exercise_kind
: 'rapidi',
n: typeof frontMatter.n === 'string' ? frontMatter.n : undefined,
lessonId:
typeof frontMatter.lesson_id === 'string'
? frontMatter.lesson_id
: undefined,
difficulty:
typeof frontMatter.difficulty === 'string'
? frontMatter.difficulty
: undefined,
time: typeof frontMatter.time === 'string' ? frontMatter.time : undefined,
assignedIn: {
volume: assignedIn.volume,
docId: assignedIn.docId,
title: lessons[assignedIn.key].title,
},
theory,
};
exercises[docId] = record;

// Backlink inverso: l'esercizio è «assegnato in» assignedIn → lo
// elenchiamo su quella lezione. La teoria NON genera backlink (non è
// assegnazione, solo supporto).
(byLesson[assignedIn.key] ??= []).push({
docId,
title,
kind: record.kind,
difficulty: record.difficulty,
time: record.time,
});
}

// `lessons` serve solo qui per validare i riferimenti: non finisce nel
// global data (i client usano solo `exercises` e `byLesson`).
return { exercises, byLesson };
},

async contentLoaded({ content, actions }) {
actions.setGlobalData(content);
},

getPathsToWatch() {
return VOLUMES.map((v) => path.join(volumeDir(v), '**/*.{md,mdx}'));
},
};
};

module.exports.PLUGIN_NAME = PLUGIN_NAME;
75 changes: 70 additions & 5 deletions sidebars/apprendista.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,76 @@
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';

// Biblioteca dell'Apprendista (Volume 4 — esercizi e laboratori).
// Sidebar per percorso, identica struttura agli altri volumi.
//
// 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 <h2> (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 */ ],
// },
];

const sidebars: SidebarsConfig = {
it: ['intro'],
liceo: ['intro'],
its: ['intro'],
it: tree,
liceo: tree,
its: tree,
};

export default sidebars;
58 changes: 58 additions & 0 deletions src/components/AssignedExercises/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* AssignedExercises — elenco «Esercizi assegnati» mostrato in fondo a una
* lezione (Volumi 1–3). È DERIVATO: legge la mappa inversa `byLesson` dal global
* data del plugin `exercise-graph` per la chiave «<volume>/<docId>» della lezione
* corrente e renderizza una card per esercizio (riusa <ExerciseLink>).
*
* Viene auto-iniettato dallo swizzle DocItem/Footer; le lezioni non vengono
* mai editate a mano. Render `null` se la lezione non assegna esercizi.
*/
import { type ReactNode } from 'react';
import Heading from '@theme/Heading';
import { usePluginData } from '@docusaurus/useGlobalData';
import { useAllDocsData } from '@docusaurus/plugin-content-docs/client';
import ExerciseLink from '@site/src/components/ExerciseLink';
import {
resolvePermalink,
type ExerciseGraphData,
} from '@site/src/lib/docResolve';

import styles from './styles.module.css';

interface AssignedExercisesProps {
/** Chiave della lezione corrente: «<volume>/<docId>». */
lessonKey: string;
}

export default function AssignedExercises({
lessonKey,
}: AssignedExercisesProps): ReactNode {
const data = usePluginData('exercise-graph') as ExerciseGraphData | undefined;
const allData = useAllDocsData();
const items = data?.byLesson?.[lessonKey];
if (!items || items.length === 0) return null;

return (
<section className={styles.section} aria-label="Esercizi assegnati">
<Heading as="h2" className={styles.heading}>
Esercizi assegnati
</Heading>
<div className={styles.list}>
{items.map((item) => {
const to = resolvePermalink(allData, 'apprendista', item.docId);
if (!to) return null;
return (
<ExerciseLink
key={item.docId}
to={to}
difficulty={item.difficulty}
time={item.time}
>
{item.title}
</ExerciseLink>
);
})}
</div>
</section>
);
}
Loading
Loading