diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 1c1b903..5985ed5 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -128,6 +128,7 @@ export default async function createConfig(): Promise { plugins: [ './plugins/pyrunner/index.js', './plugins/exercise-graph/index.js', + './plugins/copy-page-md/index.js', [ '@docusaurus/plugin-content-docs', { diff --git a/package-lock.json b/package-lock.json index 487b803..9d1e600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "python-doesnt-byte", - "version": "0.8.1", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "python-doesnt-byte", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@codemirror/commands": "^6.10.3", "@codemirror/lang-python": "^6.2.1", diff --git a/package.json b/package.json index 2d94ee7..ba81f34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "python-doesnt-byte", - "version": "0.8.1", + "version": "0.9.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/plugins/copy-page-md/index.js b/plugins/copy-page-md/index.js new file mode 100644 index 0000000..812b8e1 --- /dev/null +++ b/plugins/copy-page-md/index.js @@ -0,0 +1,191 @@ +const path = require('path'); +const fs = require('fs'); +const { + parseMarkdownFile, + DEFAULT_PARSE_FRONT_MATTER, +} = require('@docusaurus/utils'); + +const PLUGIN_NAME = 'copy-page-md'; + +// Placeholder per i componenti interattivi che non hanno senso in Markdown. +const OMITTED = '_[contenuto interattivo omesso — vedi la pagina online]_'; + +// ─── Sanitizzazione MDX → Markdown ─────────────────────────────────────────── +// Il corpus segue convenzioni documentate (CLAUDE.md): per la v1 bastano regole +// stringa. Obiettivo: «abbastanza pulito da farsi leggere da un LLM», non +// Markdown perfetto. Upgrade futuro: un passo remark/AST. + +// Inline del file referenziato da `` come blocco +// ```python. Best-effort: se il file manca, lascia un placeholder. +function inlinePyRunnerSrc(src, siteDir) { + try { + const abs = path.join(siteDir, 'static', 'py-examples', src); + const code = fs.readFileSync(abs, 'utf-8').trimEnd(); + return `\`\`\`python\n${code}\n\`\`\``; + } catch { + return OMITTED; + } +} + +// Componenti interattivi che non hanno una resa testuale utile → placeholder. +const INTERACTIVE = [ + 'Quiz', + 'QuizDeck', + 'Algorithm', + 'SQLRunner', + 'PyRunnerCustom', + 'PyRunner', +]; + +// Trasforma il JSX della PROSA (i blocchi/inline di codice sono già protetti dal +// chiamante, così esempi che mostrano `` non vengono toccati). +function transformJsx(s, siteDir) { + // Righe di import/export MDX e commenti JSX `{/* … */}`. + s = s.replace(/^\s*(?:import|export)\s.+$/gm, ''); + s = s.replace(/\{\/\*[\s\S]*?\*\/\}/g, ''); + + // → inline del .py (prima delle OMITTED su PyRunner). + s = s.replace( + /]*\bsrc=["']([^"']+)["'][^>]*\/>/g, + (_m, src) => inlinePyRunnerSrc(src, siteDir), + ); + + // x → `x` (tollera attributi, es. kind="keyword"). + s = s.replace(/]*>([\s\S]*?)<\/InlineCode>/g, '`$1`'); + + // parola → parola (tiene i children, scarta la def). + s = s.replace(/]*>([\s\S]*?)<\/Tooltip>/g, '$1'); + + // → intestazione **X** (i wrapper Tabs/TabItem li + // toglie la catch-all, tenendo il contenuto delle tab). + s = s.replace( + /]*\blabel=["']([^"']+)["'][^>]*>/g, + '\n**$1**\n\n', + ); + + // Componenti interattivi → placeholder (forma a blocco e self-closing). + for (const c of INTERACTIVE) { + s = s.replace(new RegExp(`<${c}\\b[\\s\\S]*?`, 'g'), OMITTED); + s = s.replace(new RegExp(`<${c}\\b[^>]*/>`, 'g'), OMITTED); + } + + // Catch-all: rimuove i tag JSX rimasti (componenti capitalizzati) tenendone i + // children — Epigraph, Tabs, Exercise, Solution, FAIcon, DocCardList, ecc. + // `ATTRS` consuma char non-`>` oppure stringhe quotate intere: così il match + // si ferma al `>` che chiude davvero il tag e non al primo `>` dentro un + // valore d'attributo (es. explainPrompt="se a > b allora…"). + const ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + s = s.replace(new RegExp(`<[A-Z][A-Za-z0-9]*\\b${ATTRS}/>`, 'g'), ''); // self-closing + s = s.replace(new RegExp(`<[A-Z][A-Za-z0-9]*\\b${ATTRS}>`, 'g'), ''); // apertura + s = s.replace(/<\/[A-Z][A-Za-z0-9]*>/g, ''); // chiusura + + return s; +} + +function sanitize(body, siteDir) { + // Normalizza l'info-string dei fence (solo le righe di apertura: i chiusi sono + // ``` nudi e non matchano `py`/`sql`). + let s = body + .replace(/^(\s*)```py\b[^\n]*$/gm, '$1```python') + .replace(/^(\s*)```sql\b[^\n]*$/gm, '$1```sql'); + + // Proteggi i blocchi di codice e l'inline code: il JSX-stripping non deve + // toccare esempi che contengono ``. + const blocks = []; + s = s.replace(/```[\s\S]*?```/g, (m) => `\x00B${blocks.push(m) - 1}\x00`); + const inlines = []; + s = s.replace(/`[^`\n]+`/g, (m) => `\x00I${inlines.push(m) - 1}\x00`); + + s = transformJsx(s, siteDir); + + // Ripristina (inline prima, poi blocchi). + s = s.replace(/\x00I(\d+)\x00/g, (_m, i) => inlines[+i]); + s = s.replace(/\x00B(\d+)\x00/g, (_m, i) => blocks[+i]); + + // Collassa run di righe vuote (≥3) introdotti dalle rimozioni. + return s.replace(/\n{3,}/g, '\n\n').trim(); +} + +async function toCleanMarkdown(absPath, doc, context) { + const fileContent = fs.readFileSync(absPath, 'utf-8'); + const { content } = await parseMarkdownFile({ + filePath: absPath, + fileContent, + parseFrontMatter: DEFAULT_PARSE_FRONT_MATTER, + removeContentTitle: true, + }); + + const siteUrl = context.siteConfig.url.replace(/\/$/, ''); + const pageUrl = siteUrl + doc.permalink; + const body = sanitize(content, context.siteDir); + + return ( + `# ${doc.title}\n\n` + + `> Fonte: ${pageUrl} — «Python Doesn’t Byte»\n\n` + + `${body}\n` + ); +} + +// Percorso di output del .md, coerente col fetch lato client. Il permalink +// include il baseUrl: lo si toglie perché outDir è già la radice servita a +// baseUrl. Permalink con `/` finale (landing/category) → index.md. +function outFile(outDir, permalink, baseUrl) { + let rel = permalink.startsWith(baseUrl) + ? permalink.slice(baseUrl.length) + : permalink.replace(/^\//, ''); + rel = rel.replace(/^\//, ''); + return rel === '' || rel.endsWith('/') + ? path.join(outDir, rel, 'index.md') + : path.join(outDir, `${rel}.md`); +} + +module.exports = function copyPageMd(context) { + // Manifest condiviso tra allContentLoaded (calcolo) e postBuild (scrittura). + let manifest = []; // [{ permalink, md }] + + return { + name: PLUGIN_NAME, + + async allContentLoaded({ allContent, actions }) { + manifest = []; + for (const [pluginName, byInstance] of Object.entries(allContent)) { + if (!pluginName.includes('plugin-content-docs')) continue; + for (const data of Object.values(byInstance)) { + if (!data || !Array.isArray(data.loadedVersions)) continue; + const version = + data.loadedVersions.find((v) => v.versionName === 'current') ?? + data.loadedVersions[0]; + for (const doc of version?.docs ?? []) { + const abs = doc.source.replace(/^@site/, context.siteDir); + try { + const md = await toCleanMarkdown(abs, doc, context); + manifest.push({ permalink: doc.permalink, md }); + } catch (err) { + console.warn( + `[${PLUGIN_NAME}] salto ${doc.permalink}: ${err.message}`, + ); + } + } + } + } + + // Dev (`npm start`): postBuild non gira → esponi il Markdown via global + // data, così «Copia pagina» funziona senza file statici. In produzione + // resta `{}` per non gonfiare il bundle: i .md sono file statici servibili. + const pages = + process.env.NODE_ENV === 'production' + ? {} + : Object.fromEntries(manifest.map((m) => [m.permalink, m.md])); + actions.setGlobalData({ pages }); + }, + + async postBuild({ outDir, siteConfig }) { + for (const { permalink, md } of manifest) { + const file = outFile(outDir, permalink, siteConfig.baseUrl); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, md, 'utf-8'); + } + console.log(`[${PLUGIN_NAME}] scritti ${manifest.length} file .md`); + }, + }; +}; diff --git a/src/components/CopyPageButton/index.tsx b/src/components/CopyPageButton/index.tsx new file mode 100644 index 0000000..2f14327 --- /dev/null +++ b/src/components/CopyPageButton/index.tsx @@ -0,0 +1,224 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import clsx from 'clsx'; +import { + useFloating, + autoUpdate, + offset, + flip, + shift, + useClick, + useDismiss, + useRole, + useListNavigation, + useInteractions, + FloatingPortal, + FloatingFocusManager, +} from '@floating-ui/react'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import { usePluginData } from '@docusaurus/useGlobalData'; +import Icon from '@site/src/components/Icon'; +import { copyToClipboard } from '@site/src/theme/PyRunner/clipboard'; +import { AI_PROVIDERS, buildAiPrompt } from './providers'; +import styles from './styles.module.css'; + +// Padding per flip/shift: in alto vale come clearance della navbar fissa di +// Docusaurus (≈60px), come nel Tooltip. +const PADDING = { top: 68, right: 8, bottom: 8, left: 8 }; +const RESET_MS = 1500; + +type CopyState = 'idle' | 'done' | 'error'; + +interface CopyPageData { + // In dev (`npm start`) il plugin espone qui il Markdown di ogni pagina (no + // file statici). In produzione è `{}`: si fa fetch del `.md` statico. + pages?: Record; +} + +// Percorso del `.md` accanto alla pagina. Il permalink include già il baseUrl; +// i permalink che finiscono con `/` (landing/category) mappano su `index.md`, +// coerente con quanto scrive il plugin in build. +function mdPath(permalink: string): string { + return permalink.endsWith('/') ? `${permalink}index.md` : `${permalink}.md`; +} + +// Gate: il chip vive nelle briciole, condivise con le pagine category +// (generated-index) che NON hanno DocProvider. useDoc() lancia lì: lo +// intercettiamo. useDoc chiama esattamente un hook (useContext) prima di +// lanciare, quindi il try/catch non altera il conteggio degli hook. +export default function CopyPageButton(): ReactNode { + let permalink: string; + try { + // eslint-disable-next-line react-hooks/rules-of-hooks + permalink = useDoc().metadata.permalink; + } catch { + return null; + } + return ; +} + +function CopyPageMenu({ permalink }: { permalink: string }): ReactNode { + const data = usePluginData('copy-page-md') as CopyPageData | undefined; + + const [open, setOpen] = useState(false); + const [copyState, setCopyState] = useState('idle'); + const [activeIndex, setActiveIndex] = useState(null); + const resetTimer = useRef(undefined); + const listRef = useRef>([]); + + useEffect(() => () => window.clearTimeout(resetTimer.current), []); + + const relMdUrl = mdPath(permalink); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange: setOpen, + placement: 'bottom-end', + whileElementsMounted: autoUpdate, + middleware: [ + offset(8), + flip({ padding: PADDING }), + shift({ padding: PADDING }), + ], + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: 'menu' }); + const listNav = useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + }); + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [click, dismiss, role, listNav], + ); + + const getMarkdown = useCallback(async (): Promise => { + const cached = data?.pages?.[permalink]; + if (cached) return cached; + const res = await fetch(relMdUrl); + if (!res.ok) throw new Error(`md ${res.status}`); + return res.text(); + }, [data, permalink, relMdUrl]); + + const handleCopy = useCallback(() => { + window.clearTimeout(resetTimer.current); + getMarkdown() + .then((md) => copyToClipboard(md)) + .then(() => setCopyState('done')) + .catch(() => setCopyState('error')) + .finally(() => { + resetTimer.current = window.setTimeout(() => { + setCopyState('idle'); + setOpen(false); + }, RESET_MS); + }); + }, [getMarkdown]); + + const handleProvider = useCallback( + (url: string) => { + // URL assoluto del .md calcolato dall'origin reale a runtime (non da + // siteConfig.url): così punta sempre all'host da cui la pagina è + // realmente servita — dominio custom, fallback GitHub Pages o deploy di + // preview — invece di una sola URL canonica fissata a build-time. + const mdUrl = window.location.origin + relMdUrl; + const target = url + encodeURIComponent(buildAiPrompt(mdUrl)); + window.open(target, '_blank', 'noopener,noreferrer'); + setOpen(false); + }, + [relMdUrl], + ); + + const copyLabel = + copyState === 'done' + ? 'Copiato' + : copyState === 'error' + ? 'Copia non riuscita' + : 'Copia pagina'; + + return ( +
+ + + {open && ( + + +
+ + +
+

Chiedi una spiegazione a

+ + {AI_PROVIDERS.map((p, i) => ( + + ))} +
+ + + )} +
+ ); +} diff --git a/src/components/CopyPageButton/providers.ts b/src/components/CopyPageButton/providers.ts new file mode 100644 index 0000000..4c79278 --- /dev/null +++ b/src/components/CopyPageButton/providers.ts @@ -0,0 +1,55 @@ +import type { IconName } from '@site/src/components/Icon'; + +/** + * Assistenti AI offerti dal dropdown «Copia pagina». Ogni `url` è il template a + * cui appendere `encodeURIComponent(prompt)`: l'AI riceve solo un prompt corto + * che cita l'URL del `.md` ripulito e fa lei il fetch della pagina (vedi la + * nota reference-copy-page-llm-button). Nessun contenuto utente arbitrario + * passa per l'URL. + */ +export interface AiProvider { + id: string; + label: string; + icon: IconName; + url: string; +} + +export const AI_PROVIDERS: readonly AiProvider[] = [ + // NB: Gemini è escluso di proposito. L'app web (gemini.google.com/app) NON + // supporta nativamente il prefill via URL (`?q=` / `?prompt=`): funziona solo + // con un'estensione Chrome di terze parti, quindi per i nostri utenti sarebbe + // un link che apre Gemini vuoto. Riconsiderare se Google aggiunge il supporto. + { + id: 'claude', + label: 'Claude', + icon: 'claude', + url: 'https://claude.ai/new?q=', + }, + { id: 'grok', label: 'Grok', icon: 'grok', url: 'https://grok.com/?q=' }, + { + id: 'chatgpt', + label: 'ChatGPT', + icon: 'openai', + url: 'https://chatgpt.com/?q=', + }, + { + id: 'perplexity', + label: 'Perplexity', + icon: 'perplexity', + url: 'https://www.perplexity.ai/search?q=', + }, +] as const; + +/** + * Prompt italiano coerente col tono di `DEFAULT_EXPLAIN_PROMPT` del PyRunner. + * Cita l'URL del `.md` e chiede una spiegazione adatta a uno studente. + */ +export function buildAiPrompt(mdUrl: string): string { + return ( + 'Sto studiando dal manuale scolastico «Python Doesn’t Byte». ' + + 'Leggi questa pagina e spiegamela in modo semplice, come a uno studente di ' + + '15 anni alle prime armi: dammi un riassunto chiaro e nomina i concetti ' + + 'chiave. Rispondi in italiano.\n\n' + + mdUrl + ); +} diff --git a/src/components/CopyPageButton/styles.module.css b/src/components/CopyPageButton/styles.module.css new file mode 100644 index 0000000..05fe87e --- /dev/null +++ b/src/components/CopyPageButton/styles.module.css @@ -0,0 +1,137 @@ +.root { + display: inline-flex; +} + +/* ── Trigger ─────────────────────────────────────────────────────────────── */ +.trigger { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 12px; + font-family: var(--font-mono-ui); + font-size: 12px; + font-weight: 500; + line-height: 1; + color: var(--at-fg-body); + background: var(--at-bg-subtle); + border: 1px solid var(--at-border); + border-radius: var(--radius-pill); + cursor: pointer; + transition: + color var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out); +} + +.trigger:hover { + color: var(--at-accent); + border-color: var(--at-accent); + background: var(--at-accent-bg); +} + +.trigger:focus-visible { + outline: 2px solid var(--at-accent); + outline-offset: 2px; +} + +.trigger:active { + transform: scale(0.98); +} + +.triggerIcon { + color: var(--at-muted); + transition: color var(--dur-fast) var(--ease-out); +} + +.trigger:hover .triggerIcon { + color: var(--at-accent); +} + +.chevron { + color: var(--at-muted); + transition: transform var(--dur-normal) var(--ease-out); +} + +.chevronOpen { + transform: rotate(180deg); +} + +/* ── Menu ────────────────────────────────────────────────────────────────── */ +.menu { + z-index: 100; + min-width: 220px; + padding: 6px; + display: flex; + flex-direction: column; + border-radius: var(--radius-md); + background: var(--at-bg-panel); + border: 1px solid var(--at-border-strong); + box-shadow: var(--shadow-elevated); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + font-family: var(--font-mono-ui); + font-size: 13px; + font-weight: 500; + line-height: 1.2; + text-align: left; + color: var(--at-fg-body); + background: none; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: + color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +.item:hover, +.item:focus-visible { + color: var(--at-accent); + background: var(--at-accent-bg); + outline: none; +} + +.item:active { + transform: scale(0.98); +} + +.itemDone { + color: var(--at-accent); +} + +.itemIcon { + color: var(--at-muted); + transition: color var(--dur-fast) var(--ease-out); +} + +.item:hover .itemIcon, +.item:focus-visible .itemIcon, +.itemDone .itemIcon { + color: var(--at-accent); +} + +.sep { + height: 1px; + margin: 6px 4px; + background: var(--at-border); +} + +.groupLabel { + margin: 2px 0 4px; + padding: 0 10px; + font-family: var(--font-mono-ui); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--at-faint); +} diff --git a/src/components/Icon/icons.ts b/src/components/Icon/icons.ts index 5204bfd..648cc98 100644 --- a/src/components/Icon/icons.ts +++ b/src/components/Icon/icons.ts @@ -59,7 +59,14 @@ export type IconName = | 'flask' // Soluzione (tieni-premuto-per-rivelare) | 'puzzle' - | 'hand-pointer'; + | 'hand-pointer' + // Bottone «Spiegamelo facile» → assistenti AI + | 'microchip-ai' + | 'chevron-down' + | 'openai' + | 'claude' + | 'perplexity' + | 'grok'; export const DUOTONE: Record = { heart: { @@ -139,6 +146,47 @@ export const DUOTONE: Record = { brand: true, p: 'M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.1.39-1.99 1.03-2.69-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.69 0 3.84-2.34 4.68-4.57 4.93.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2', }, + // ── Bottone «Spiegamelo facile» ───────────────────────────────────────── + // Icona del chip e del bottone "Spiegamelo facile" dei runner (FA Pro 7.1.0 + // duotone): chip = microchip, glifo primario = "AI". + 'microchip-ai': { + vb: '0 0 512 512', + s: 'M0 152c0 13.3 10.7 24 24 24l40 0 0-48-40 0c-13.3 0-24 10.7-24 24zM0 256c0 13.3 10.7 24 24 24l40 0 0-48-40 0c-13.3 0-24 10.7-24 24zM0 360c0 13.3 10.7 24 24 24l40 0 0-48-40 0c-13.3 0-24 10.7-24 24zM128 24l0 40 48 0 0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24zm0 424l0 40c0 13.3 10.7 24 24 24s24-10.7 24-24l0-40-48 0zM232 24l0 40 48 0 0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24zm0 424l0 40c0 13.3 10.7 24 24 24s24-10.7 24-24l0-40-48 0zM336 24l0 40 48 0 0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24zm0 424l0 40c0 13.3 10.7 24 24 24s24-10.7 24-24l0-40-48 0zM448 128l0 48 40 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-40 0zm0 104l0 48 40 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-40 0zm0 104l0 48 40 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-40 0z', + p: 'M64 128c0-35.3 28.7-64 64-64l256 0c35.3 0 64 28.7 64 64l0 256c0 35.3-28.7 64-64 64l-256 0c-35.3 0-64-28.7-64-64l0-256zm272 44c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-128c0-11-9-20-20-20zM226.3 184c-3.2-7.3-10.4-12-18.3-12s-15.1 4.7-18.3 12l-56 128c-4.4 10.1 .2 21.9 10.3 26.3s21.9-.2 26.3-10.3l5.3-12 64.8 0 5.3 12c4.4 10.1 16.2 14.7 26.3 10.3s14.7-16.2 10.3-26.3l-56-128zM208 241.9l14.9 34.1-29.8 0 14.9-34.1z', + }, + // Chevron del trigger (single-layer, FA solid). + 'chevron-down': { + vb: '0 0 512 512', + s: 'M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z', + p: '', + }, + // Loghi ufficiali degli assistenti AI (brand single-layer, 24×24). + // Fonti: openai → simple-icons (v11, prima della rimozione per policy brand); + // claude/perplexity → simple-icons; grok → lobehub/lobe-icons. + openai: { + vb: '0 0 24 24', + s: '', + brand: true, + p: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z', + }, + claude: { + vb: '0 0 24 24', + s: '', + brand: true, + p: 'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z', + }, + perplexity: { + vb: '0 0 24 24', + s: '', + brand: true, + p: 'M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z', + }, + grok: { + vb: '0 0 24 24', + s: '', + brand: true, + p: 'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815', + }, // Pagine legali (privacy, termini, licenza, donazioni) + un tocco geek. fingerprint: { vb: '0 0 512 512', diff --git a/src/css/custom.css b/src/css/custom.css index 0ba2e33..4aab172 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -952,6 +952,15 @@ pre code, display: block; } +/* TOC collassabile (solo mobile): il default Docusaurus usa --ifm-global-radius + (6.4px), fuori dalla nostra scala radius → stona accanto agli altri box/chip. + Lo riportiamo a --radius-md, come gli altri pannelli. Classe raddoppiata per + battere la specificità del CSS module .tocCollapsible_… (stessa specificità, + ma vince per ordine di cascata). */ +.theme-doc-toc-mobile.theme-doc-toc-mobile { + border-radius: var(--radius-md); +} + /* ─────────────── Atmospheric — Generated index pages (category landings) ─────────────── */ main [class*='generatedIndexPage'] > header > h1, diff --git a/src/theme/DocBreadcrumbs/index.tsx b/src/theme/DocBreadcrumbs/index.tsx index f5964f8..749efef 100644 --- a/src/theme/DocBreadcrumbs/index.tsx +++ b/src/theme/DocBreadcrumbs/index.tsx @@ -29,6 +29,7 @@ import HomeBreadcrumbItem from '@theme/DocBreadcrumbs/Items/Home'; import DocBreadcrumbsStructuredData from '@theme/DocBreadcrumbs/StructuredData'; import { VOLUME_LABELS, volumeShortByLabel } from '@site/src/lib/docResolve'; +import CopyPageButton from '@site/src/components/CopyPageButton'; import styles from './styles.module.css'; @@ -136,6 +137,10 @@ export default function DocBreadcrumbs(): ReactNode { ); })} + {/* Self-gate: null fuori da una lezione (pagine category). */} +
+ +
); diff --git a/src/theme/DocBreadcrumbs/styles.module.css b/src/theme/DocBreadcrumbs/styles.module.css index 60c86d2..353b655 100644 --- a/src/theme/DocBreadcrumbs/styles.module.css +++ b/src/theme/DocBreadcrumbs/styles.module.css @@ -10,4 +10,42 @@ .breadcrumbsContainer { --ifm-breadcrumb-size-multiplier: 0.8; margin-bottom: 0.8rem; + /* Briciole a sinistra, chip «Spiegamelo facile» ancorato a destra. Il + container NON va a capo (nowrap): a mandare a capo è la lista delle + briciole (già flex-wrap in Infima), che si restringe per far spazio al + chip. Così il chip resta sempre sulla prima riga, in alto a destra, + invece di scivolare sotto «allineato a niente». */ + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + gap: 8px 12px; +} + +/* La lista briciole è il flex item elastico: cresce per spingere il chip a + destra, ma può anche restringersi (min-width: 0) e andare a capo al suo + interno quando il percorso è lungo. */ +.breadcrumbsContainer > :global(.breadcrumbs) { + flex: 1 1 auto; + min-width: 0; + margin-bottom: 0; +} + +.action { + flex-shrink: 0; +} + +/* Mobile: niente affiancamento. Tenere il chip in riga obbligherebbe le + briciole a incolonnarsi (una parola per riga), specie negli esercizi col + percorso lungo. Si impila: briciole sopra a piena larghezza, chip sotto. */ +@media (max-width: 768px) { + .breadcrumbsContainer { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .breadcrumbsContainer > :global(.breadcrumbs) { + flex: 0 1 auto; + width: 100%; + } } diff --git a/src/theme/PyRunner/Toolbar.tsx b/src/theme/PyRunner/Toolbar.tsx index 68c81d4..884df00 100644 --- a/src/theme/PyRunner/Toolbar.tsx +++ b/src/theme/PyRunner/Toolbar.tsx @@ -5,12 +5,12 @@ import { faRotateLeft, faStop, faExpand, - faWandMagicSparkles, faDatabase, } from '@fortawesome/free-solid-svg-icons'; import clsx from 'clsx'; import IconCopy from '@theme/Icon/Copy'; import IconSuccess from '@theme/Icon/Success'; +import Icon from '@site/src/components/Icon'; import { copyToClipboard } from './clipboard'; import styles from './styles.module.css'; import type { RunStatus } from './types'; @@ -99,7 +99,7 @@ export function Toolbar({ aria-label="Spiegamelo facile (copia prompt)" title="Copia un prompt per chiedere a un’IA di spiegarti questo codice" > - + )} {showFullscreen && onFullscreen && (