feat: custom evaluation config
This commit is contained in:
parent
120a1338c0
commit
e4ec3f1ded
|
|
@ -198,6 +198,7 @@ Untuk materi yang menggunakan simulator Arduino (Velxio):
|
||||||
| `---EXPECTED_SERIAL_OUTPUT---` | Output serial yang diharapkan (subsequence match) |
|
| `---EXPECTED_SERIAL_OUTPUT---` | Output serial yang diharapkan (subsequence match) |
|
||||||
| `---EXPECTED_WIRING---` | Wiring yang harus dibuat siswa (JSON, lenient) |
|
| `---EXPECTED_WIRING---` | Wiring yang harus dibuat siswa (JSON, lenient) |
|
||||||
| `---KEY_TEXT---` | Kata kunci yang harus ada di kode siswa |
|
| `---KEY_TEXT---` | Kata kunci yang harus ada di kode siswa |
|
||||||
|
| `---EVALUATION_CONFIG---` | Konfigurasi tambahan evaluasi Arduino (JSON: e.g. `timeout_ms` dalam milidetik) |
|
||||||
|
|
||||||
Contoh materi Arduino:
|
Contoh materi Arduino:
|
||||||
|
|
||||||
|
|
@ -246,6 +247,12 @@ LED OFF
|
||||||
pinMode
|
pinMode
|
||||||
digitalWrite
|
digitalWrite
|
||||||
---END_KEY_TEXT---
|
---END_KEY_TEXT---
|
||||||
|
|
||||||
|
---EVALUATION_CONFIG---
|
||||||
|
{
|
||||||
|
"timeout_ms": 8000
|
||||||
|
}
|
||||||
|
---END_EVALUATION_CONFIG---
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Referensi Nama Pin Komponen Velxio
|
##### Referensi Nama Pin Komponen Velxio
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@ Elemes mendukung beberapa mode lesson melalui **marker** di file markdown. Mode
|
||||||
| `---KEY_TEXT_CIRCUIT---` / `---END_KEY_TEXT_CIRCUIT---` | Keyword wajib di circuit (hybrid) |
|
| `---KEY_TEXT_CIRCUIT---` / `---END_KEY_TEXT_CIRCUIT---` | Keyword wajib di circuit (hybrid) |
|
||||||
| `---SOLUTION_CODE---` / `---END_SOLUTION_CODE---` | Solusi kode (ditampilkan setelah selesai) |
|
| `---SOLUTION_CODE---` / `---END_SOLUTION_CODE---` | Solusi kode (ditampilkan setelah selesai) |
|
||||||
| `---SOLUTION_CIRCUIT---` / `---END_SOLUTION_CIRCUIT---` | Solusi circuit |
|
| `---SOLUTION_CIRCUIT---` / `---END_SOLUTION_CIRCUIT---` | Solusi circuit |
|
||||||
|
| `---EVALUATION_CONFIG---` / `---END_EVALUATION_CONFIG---` | Konfigurasi tambahan evaluasi Arduino (JSON: e.g. `timeout_ms`) |
|
||||||
|
|
||||||
### Fitur Tombol "Coba" (Code Try-out)
|
### Fitur Tombol "Coba" (Code Try-out)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,5 +14,14 @@
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||||
|
.then(reg => console.log('[SW] Registered:', reg.scope))
|
||||||
|
.catch(err => console.warn('[SW] Registration failed:', err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,21 @@
|
||||||
let ready = $state(false);
|
let ready = $state(false);
|
||||||
let simApi = $state<CircuitJSApi | null>(null);
|
let simApi = $state<CircuitJSApi | null>(null);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let saveTimeout: ReturnType<typeof setTimeout>;
|
let saveTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
let autoSaveInterval: ReturnType<typeof setInterval>;
|
let isDestroyed = false;
|
||||||
let lastLoadedCircuit = $state('');
|
let lastLoadedCircuit = $state('');
|
||||||
let lastStorageKey = $state<string | undefined>(undefined);
|
let lastStorageKey = $state<string | undefined>(undefined);
|
||||||
|
|
||||||
function saveToStorage(text: string) {
|
function saveToStorage(text: string) {
|
||||||
if (!storageKey) return;
|
if (!storageKey || isDestroyed) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
clearTimeout(saveTimeout);
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
saveTimeout = setTimeout(() => {
|
saveTimeout = setTimeout(() => {
|
||||||
localStorage.setItem(storageKey, text);
|
if (!isDestroyed && storageKey) {
|
||||||
|
localStorage.setItem(storageKey, text);
|
||||||
|
}
|
||||||
saving = false;
|
saving = false;
|
||||||
|
saveTimeout = null;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,8 +140,11 @@
|
||||||
// Cleanup timers on destroy
|
// Cleanup timers on destroy
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(autoSaveInterval);
|
isDestroyed = true;
|
||||||
clearTimeout(saveTimeout);
|
if (saveTimeout) {
|
||||||
|
clearTimeout(saveTimeout);
|
||||||
|
saveTimeout = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,12 @@ export function initVelxioBridge(
|
||||||
arduinoCircuitKey: string,
|
arduinoCircuitKey: string,
|
||||||
arduinoCodeKey: string,
|
arduinoCodeKey: string,
|
||||||
onReady: (bridge: VelxioBridge) => void,
|
onReady: (bridge: VelxioBridge) => void,
|
||||||
onSubmit: () => void
|
onSubmit: () => void,
|
||||||
) {
|
onCountdown?: (msRemaining: number) => void
|
||||||
|
): () => void {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const onMessage = (e: MessageEvent) => {
|
const onMessage = (e: MessageEvent) => {
|
||||||
const type = e.data?.type;
|
const type = e.data?.type;
|
||||||
if (!type) return;
|
if (!type) return;
|
||||||
|
|
@ -83,8 +86,30 @@ export function initVelxioBridge(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'velxio:compile_result' && e.data.success) {
|
if (type === 'velxio:compile_result' && e.data.success) {
|
||||||
const timeout = data?.evaluation_config?.timeout_ms ?? 5000;
|
const timeout = data?.evaluation_config?.timeout_ms ?? 8000;
|
||||||
setTimeout(() => onSubmit(), timeout);
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
|
let remaining = timeout;
|
||||||
|
if (onCountdown) {
|
||||||
|
onCountdown(remaining);
|
||||||
|
countdownInterval = setInterval(() => {
|
||||||
|
remaining -= 1000;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
if (countdownInterval) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
countdownInterval = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onCountdown(remaining);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (countdownInterval) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
countdownInterval = null;
|
||||||
|
}
|
||||||
|
onSubmit();
|
||||||
|
}, timeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('message', onMessage);
|
window.addEventListener('message', onMessage);
|
||||||
|
|
@ -136,10 +161,18 @@ export function initVelxioBridge(
|
||||||
} catch { /* cross-origin or not ready yet */ }
|
} catch { /* cross-origin or not ready yet */ }
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
setTimeout(() => {
|
const cleanup = () => {
|
||||||
clearInterval(pollReady);
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
window.removeEventListener('message', onMessage);
|
window.removeEventListener('message', onMessage);
|
||||||
|
clearInterval(pollReady);
|
||||||
|
if (countdownInterval) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
countdownInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,14 @@
|
||||||
|
|
||||||
beforeNavigate(() => {
|
beforeNavigate(() => {
|
||||||
lessonContext.set(null);
|
lessonContext.set(null);
|
||||||
if (mgr.velxioBridge) { mgr.velxioBridge.destroy(); mgr.velxioBridge = null; }
|
if (mgr.velxioCleanup) {
|
||||||
|
mgr.velxioCleanup();
|
||||||
|
mgr.velxioCleanup = null;
|
||||||
|
}
|
||||||
|
if (mgr.velxioBridge) {
|
||||||
|
mgr.velxioBridge.destroy();
|
||||||
|
mgr.velxioBridge = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let contentEl = $state<HTMLElement | null>(null);
|
let contentEl = $state<HTMLElement | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export class LessonManager {
|
||||||
velxioSaving = $state(false);
|
velxioSaving = $state(false);
|
||||||
velxioError = $state(false);
|
velxioError = $state(false);
|
||||||
velxioIframe = $state<HTMLIFrameElement | null>(null);
|
velxioIframe = $state<HTMLIFrameElement | null>(null);
|
||||||
|
velxioCleanup = $state<(() => void) | null>(null);
|
||||||
|
|
||||||
showSolution = $state(false);
|
showSolution = $state(false);
|
||||||
activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart'>('info');
|
activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart'>('info');
|
||||||
|
|
@ -177,6 +178,7 @@ export class LessonManager {
|
||||||
this.circuitPassed = false;
|
this.circuitPassed = false;
|
||||||
this.showSolution = false;
|
this.showSolution = false;
|
||||||
|
|
||||||
|
if (this.velxioCleanup) { this.velxioCleanup(); this.velxioCleanup = null; }
|
||||||
if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; }
|
if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; }
|
||||||
this.velxioReady = false;
|
this.velxioReady = false;
|
||||||
this.velxioError = false;
|
this.velxioError = false;
|
||||||
|
|
@ -411,7 +413,11 @@ export class LessonManager {
|
||||||
|
|
||||||
setupVelxioBridge(iframe: HTMLIFrameElement) {
|
setupVelxioBridge(iframe: HTMLIFrameElement) {
|
||||||
this.velxioIframe = iframe;
|
this.velxioIframe = iframe;
|
||||||
initVelxioBridge(
|
if (this.velxioCleanup) {
|
||||||
|
this.velxioCleanup();
|
||||||
|
this.velxioCleanup = null;
|
||||||
|
}
|
||||||
|
this.velxioCleanup = initVelxioBridge(
|
||||||
iframe,
|
iframe,
|
||||||
this.data,
|
this.data,
|
||||||
this.arduinoCircuitKey,
|
this.arduinoCircuitKey,
|
||||||
|
|
@ -420,7 +426,16 @@ export class LessonManager {
|
||||||
this.velxioBridge = bridge;
|
this.velxioBridge = bridge;
|
||||||
this.velxioReady = true;
|
this.velxioReady = true;
|
||||||
},
|
},
|
||||||
() => this.handleVelxioSubmit()
|
() => this.handleVelxioSubmit(),
|
||||||
|
(msRemaining) => {
|
||||||
|
this.activeTab = 'output';
|
||||||
|
Object.assign(this.velxioOut, {
|
||||||
|
loading: true,
|
||||||
|
output: `Kompilasi sukses. Menjalankan simulasi & merekam log serial (${Math.round(msRemaining / 1000)} detik)...`,
|
||||||
|
error: '',
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
|
|
@ -1,21 +1,32 @@
|
||||||
{
|
{
|
||||||
"name": "Elemes LMS",
|
"name": "Elemes LMS",
|
||||||
"short_name": "Elemes",
|
"short_name": "Elemes",
|
||||||
"start_url": "/",
|
"description": "Belajar Pemrograman C & Arduino",
|
||||||
"display": "standalone",
|
"start_url": "/",
|
||||||
"background_color": "#ffffff",
|
"display": "standalone",
|
||||||
"theme_color": "#0d6efd",
|
"orientation": "any",
|
||||||
"description": "Belajar Pemrograman C",
|
"background_color": "#0a0a0a",
|
||||||
"icons": [
|
"theme_color": "#0d6efd",
|
||||||
{
|
"categories": ["education"],
|
||||||
"src": "/icons/icon-192.png",
|
"icons": [
|
||||||
"sizes": "192x192",
|
{
|
||||||
"type": "image/png"
|
"src": "/icons/icon-192.png",
|
||||||
},
|
"sizes": "192x192",
|
||||||
{
|
"type": "image/png",
|
||||||
"src": "/icons/icon-512.png",
|
"purpose": "any"
|
||||||
"sizes": "512x512",
|
},
|
||||||
"type": "image/png"
|
{
|
||||||
}
|
"src": "/icons/icon-512.png",
|
||||||
]
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Daftar Pelajaran",
|
||||||
|
"url": "/",
|
||||||
|
"description": "Lihat semua pelajaran"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
// static/sw.js
|
||||||
|
const CACHE_VERSION = 'elemes-v1';
|
||||||
|
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||||
|
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||||
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
// Assets yang di-precache (Vite immutable + critical)
|
||||||
|
const PRECACHE_URLS = [
|
||||||
|
'/',
|
||||||
|
'/manifest.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install: precache minimal assets
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(STATIC_CACHE).then(cache => cache.addAll(PRECACHE_URLS))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate: hapus cache lama
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys
|
||||||
|
.filter(k => k.startsWith('elemes-') && k !== STATIC_CACHE && k !== API_CACHE && k !== ASSET_CACHE)
|
||||||
|
.map(k => caches.delete(k))
|
||||||
|
)
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Hanya handle GET
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
// NetworkOnly: API yang tidak boleh di-cache
|
||||||
|
if (url.pathname.startsWith('/api/compile') ||
|
||||||
|
url.pathname.startsWith('/api/track-progress') ||
|
||||||
|
url.pathname.startsWith('/api/login') ||
|
||||||
|
url.pathname.startsWith('/api/logout')) {
|
||||||
|
return; // biarkan browser handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheFirst: Static assets (Vite immutable dengan hash)
|
||||||
|
if (url.pathname.startsWith('/_app/immutable/')) {
|
||||||
|
event.respondWith(cacheFirst(request, STATIC_CACHE, 365));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheFirst: Gambar lesson (assets)
|
||||||
|
if (url.pathname.startsWith('/assets/')) {
|
||||||
|
event.respondWith(cacheFirst(request, ASSET_CACHE, 7));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheFirst: Font files (immutable)
|
||||||
|
if (url.hostname === 'fonts.gstatic.com') {
|
||||||
|
event.respondWith(cacheFirst(request, STATIC_CACHE, 365));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkFirst: API lesson data (cache 6 jam)
|
||||||
|
if (url.pathname.startsWith('/api/lesson/') && url.pathname.endsWith('.json')) {
|
||||||
|
event.respondWith(networkFirst(request, API_CACHE, 6 * 60));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkFirst: API lesson list (cache 1 jam)
|
||||||
|
if (url.pathname.startsWith('/api/lessons')) {
|
||||||
|
event.respondWith(networkFirst(request, API_CACHE, 60));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleWhileRevalidate: HTML pages
|
||||||
|
if (request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleWhileRevalidate: Google Fonts CSS, KaTeX
|
||||||
|
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'cdn.jsdelivr.net') {
|
||||||
|
event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function cacheFirst(request, cacheName, maxAgeDays = 7) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request, cacheName, maxAgeMinutes = 60) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
return new Response(JSON.stringify({ error: 'Offline', cached: false }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function staleWhileRevalidate(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
const networkFetch = fetch(request).then(response => {
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}).catch(() => null);
|
||||||
|
return cached || (await networkFetch) ||
|
||||||
|
new Response('Offline — halaman ini belum pernah dibuka sebelumnya.', {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,20 +6,42 @@ import os
|
||||||
import re
|
import re
|
||||||
import html as html_module
|
import html as html_module
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
import markdown as md
|
import markdown as md
|
||||||
|
|
||||||
from config import CONTENT_DIR
|
from config import CONTENT_DIR
|
||||||
|
|
||||||
|
_home_cache = {'content': None, 'mtime': -1.0}
|
||||||
|
_home_lock = Lock()
|
||||||
|
|
||||||
|
_markdown_cache = {}
|
||||||
|
_markdown_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _read_home_md():
|
def _read_home_md():
|
||||||
"""Read home.md and return its content, or empty string if missing."""
|
"""Read home.md and return its content, or empty string if missing."""
|
||||||
path = os.path.join(CONTENT_DIR, "home.md")
|
path = os.path.join(CONTENT_DIR, "home.md")
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return ""
|
return ""
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
try:
|
||||||
return f.read()
|
current_mtime = os.path.getmtime(path)
|
||||||
|
except OSError:
|
||||||
|
return _home_cache.get('content') or ""
|
||||||
|
|
||||||
|
with _home_lock:
|
||||||
|
if current_mtime != _home_cache['mtime']:
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
_home_cache['content'] = f.read()
|
||||||
|
_home_cache['mtime'] = current_mtime
|
||||||
|
# Invalidate all downstream caches
|
||||||
|
find_lesson_file.cache_clear()
|
||||||
|
get_lessons.cache_clear()
|
||||||
|
get_lesson_names.cache_clear()
|
||||||
|
get_lessons_with_learning_objectives.cache_clear()
|
||||||
|
with _markdown_lock:
|
||||||
|
_markdown_cache.clear()
|
||||||
|
return _home_cache['content']
|
||||||
|
|
||||||
|
|
||||||
def _parse_lesson_links(home_content):
|
def _parse_lesson_links(home_content):
|
||||||
|
|
@ -417,9 +439,18 @@ def _extract_section(content, start_marker, end_marker):
|
||||||
return extracted, remaining
|
return extracted, remaining
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
|
||||||
def render_markdown_content(file_path):
|
def render_markdown_content(file_path):
|
||||||
"""Parse a lesson markdown file and return structured HTML parts as a dictionary."""
|
"""Parse a lesson markdown file and return structured HTML parts as a dictionary."""
|
||||||
|
try:
|
||||||
|
current_mtime = os.path.getmtime(file_path)
|
||||||
|
except OSError:
|
||||||
|
current_mtime = 0.0
|
||||||
|
|
||||||
|
with _markdown_lock:
|
||||||
|
cached = _markdown_cache.get(file_path)
|
||||||
|
if cached and cached['mtime'] == current_mtime:
|
||||||
|
return cached['data']
|
||||||
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
|
|
@ -586,7 +617,7 @@ def render_markdown_content(file_path):
|
||||||
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
|
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
|
||||||
lesson_info_html = md.markdown(lesson_info, extensions=MD_EXTENSIONS) if lesson_info else ""
|
lesson_info_html = md.markdown(lesson_info, extensions=MD_EXTENSIONS) if lesson_info else ""
|
||||||
|
|
||||||
return {
|
parsed_data = {
|
||||||
'lesson_html': lesson_html,
|
'lesson_html': lesson_html,
|
||||||
'exercise_html': exercise_html,
|
'exercise_html': exercise_html,
|
||||||
'expected_output': expected_output,
|
'expected_output': expected_output,
|
||||||
|
|
@ -615,10 +646,21 @@ def render_markdown_content(file_path):
|
||||||
'slides': slides_html
|
'slides': slides_html
|
||||||
}
|
}
|
||||||
|
|
||||||
|
with _markdown_lock:
|
||||||
|
if len(_markdown_cache) >= 128:
|
||||||
|
_markdown_cache.clear()
|
||||||
|
_markdown_cache[file_path] = {'data': parsed_data, 'mtime': current_mtime}
|
||||||
|
|
||||||
|
return parsed_data
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def render_home_content():
|
def render_home_content():
|
||||||
"""Render the home.md intro section (before Available_Lessons) as HTML."""
|
"""Render the home.md intro section (before Available_Lessons) as HTML.
|
||||||
|
|
||||||
|
Not cached separately — relies on _read_home_md() mtime-based cache,
|
||||||
|
which is already fast (1 syscall per request). This avoids the lru_cache
|
||||||
|
multi-worker stale data problem.
|
||||||
|
"""
|
||||||
home_content = _read_home_md()
|
home_content = _read_home_md()
|
||||||
if not home_content:
|
if not home_content:
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue