diff --git a/.gitignore b/.gitignore index 6f37666e..4f517801 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,3 @@ learning_observer/learning_observer/static_data/admins.yaml .ipynb_checkpoints/ .eggs/ .next/ -modules/wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/* \ No newline at end of file diff --git a/VERSION b/VERSION index 42ec10d5..3222cfd5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.03.06T16.07.48.997Z.0bfff3a0.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.13T15.49.25.710Z.fa70160c.berickson.20260312.small.fixes diff --git a/learning_observer/VERSION b/learning_observer/VERSION index 20ad2997..449dd4d0 100644 --- a/learning_observer/VERSION +++ b/learning_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.02.27T21.25.37.849Z.3207a114.berickson.20260220.dami.portfolio.pr +0.1.0+2026.03.13T15.15.47.294Z.a9a3177c.berickson.20260312.small.fixes diff --git a/learning_observer/learning_observer/remote_assets.py b/learning_observer/learning_observer/remote_assets.py new file mode 100644 index 00000000..f485fa9a --- /dev/null +++ b/learning_observer/learning_observer/remote_assets.py @@ -0,0 +1,191 @@ +""" +learning_observer.remote_assets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Utilities for downloading and installing prebuilt frontend asset bundles +(e.g. exported Next.js apps) that are hosted in a GitHub repository. + +Intended use: LO module ``__init__.py`` files call :func:`fetch_module_assets` +from their startup checks to ensure their bundled frontend is present. + +Typical repository layout assumed by the pointer-file convention:: + + lo_assets/ + my_module/ + my_module-current.tar.gz ← plain-text file containing the tarball name + my_module-v1.2.3.tar.gz ← the actual tarball +""" + +import os +import shutil +import sys +import tarfile +import tempfile +import urllib.request + +# --------------------------------------------------------------------------- +# Default repository coordinates +# --------------------------------------------------------------------------- + +_LO_ASSETS_REPO = 'ETS-Next-Gen/lo_assets' +_LO_ASSETS_REF = 'main' + + +# --------------------------------------------------------------------------- +# Interactive / non-interactive prompts +# --------------------------------------------------------------------------- + +def confirm(prompt, default_noninteractive): + """ + Ask a yes/no question and return a boolean. + + Resolution order: + 1. *default_noninteractive* when stdin is not a TTY. + 2. An interactive prompt otherwise. + + Example usage in a module startup check:: + + should_fetch = confirm( + prompt='Download missing assets? (y/n) ', + default_noninteractive=False, + ) + """ + if not sys.stdin.isatty(): + return default_noninteractive + + return input(prompt).strip().lower() in {'y', 'yes'} + + +# --------------------------------------------------------------------------- +# Low-level network helpers +# --------------------------------------------------------------------------- + +def read_url(url): + """ + Fetch *url* and return its contents as a stripped string. + """ + with urllib.request.urlopen(url) as response: + return response.read().decode('utf-8').strip() + + +def download_file(url, destination): + """ + Stream *url* to *destination*, avoiding loading the full response into + memory. + """ + with urllib.request.urlopen(url) as response, open(destination, 'wb') as output_file: + shutil.copyfileobj(response, output_file) + + +# --------------------------------------------------------------------------- +# Archive helpers +# --------------------------------------------------------------------------- + +def find_nextjs_root(extracted_dir): + """ + Walk *extracted_dir* and return the shallowest path that looks like an + exported Next.js app (contains both ``index.html`` and a ``_next/`` + directory). + + Raises ``ValueError`` if no such directory is found. + """ + candidates = [] + for root, _, files in os.walk(extracted_dir): + if 'index.html' in files and os.path.isdir(os.path.join(root, '_next')): + candidates.append(root) + + if not candidates: + raise ValueError( + "Could not find an exported Next.js app inside the archive " + "(expected index.html and _next/ in the same directory)." + ) + + return min(candidates, key=len) + + +def extract_assets_tarball(tar_path, target_dir): + """ + Extract the Next.js app bundle at *tar_path* into *target_dir*. + + The archive may contain arbitrary wrapper directories; only the shallowest + directory that looks like an exported Next.js app is copied to + *target_dir*. Any pre-existing *target_dir* is removed first. + + Raises ``ValueError`` (from :func:`find_nextjs_root`) if the archive does + not contain a recognisable Next.js export. + """ + with tempfile.TemporaryDirectory() as temp_extract_dir: + with tarfile.open(tar_path, mode='r:gz') as archive: + archive.extractall(temp_extract_dir) + + source_root = find_nextjs_root(temp_extract_dir) + + if os.path.isdir(target_dir): + shutil.rmtree(target_dir) + shutil.copytree(source_root, target_dir) + + +# --------------------------------------------------------------------------- +# High-level fetch interface +# --------------------------------------------------------------------------- + +def fetch_module_assets( + target_dir, + pointer_file, + assets_url=None, + assets_repo=_LO_ASSETS_REPO, + assets_ref=_LO_ASSETS_REF, +): + """ + Download and install a prebuilt Next.js asset bundle for an LO module. + + Parameters + ---------- + target_dir: + Local directory where the extracted app should be placed. + pointer_file: + Path within *assets_repo* to a plain-text file whose content is the + filename of the current tarball, e.g. + ``'wo_portfolio_diff/wo_portfolio_diff-current.tar.gz'``. + The tarball is assumed to live in the same directory as this file. + assets_url: + If supplied, skip pointer-file resolution and download this URL + directly. Useful for pinning a specific release or local testing. + assets_repo: + ``owner/repo`` slug on GitHub. Defaults to the canonical LO assets + repository. + assets_ref: + Branch, tag, or commit SHA to resolve *pointer_file* against. + Defaults to ``'main'``. + + Raises + ------ + urllib.error.URLError + Network error while resolving the pointer file or downloading. + ValueError + The downloaded archive does not contain a recognisable Next.js export. + OSError + File-system error while writing or extracting the archive. + tarfile.TarError + The downloaded file is not a valid gzipped tar archive. + """ + if assets_url is None: + pointer_url = ( + f'https://raw.githubusercontent.com/{assets_repo}/{assets_ref}' + f'/{pointer_file}' + ) + resolved_name = read_url(pointer_url) + pointer_dir = pointer_file.rsplit('/', 1)[0] + assets_url = ( + f'https://raw.githubusercontent.com/{assets_repo}/{assets_ref}' + f'/{pointer_dir}/{resolved_name}' + ) + + with tempfile.NamedTemporaryFile(suffix='.tar.gz', delete=False) as tmp: + archive_path = tmp.name + + try: + download_file(assets_url, archive_path) + extract_assets_tarball(archive_path, target_dir) + finally: + os.unlink(archive_path) diff --git a/learning_observer/learning_observer/routes.py b/learning_observer/learning_observer/routes.py index 763ea621..7d67f02d 100644 --- a/learning_observer/learning_observer/routes.py +++ b/learning_observer/learning_observer/routes.py @@ -480,8 +480,22 @@ async def _nextjs_handler(request): sub_url = request.match_info.get('tail') if sub_url is None: return aiohttp.web.FileResponse(os.path.join(path, 'index.html')) - # TODO will this handle multi-layered sub-urls? /foo/bar - return aiohttp.web.FileResponse(os.path.join(path, f'{sub_url}.html')) + # Normalize the subpath and ensure it stays in the exported Next.js folder. + # This allows exported assets like `runtime-config.js` to be served directly. + relative_path = sub_url.lstrip('/') + file_path = os.path.normpath(os.path.join(path, relative_path)) + if os.path.commonpath([os.path.abspath(file_path), os.path.abspath(path)]) != os.path.abspath(path): + raise aiohttp.web.HTTPNotFound() + + if os.path.isfile(file_path): + return aiohttp.web.FileResponse(file_path) + + # Handle extension-less routes exported as html files. + html_path = os.path.normpath(os.path.join(path, f'{relative_path}.html')) + if os.path.commonpath([os.path.abspath(html_path), os.path.abspath(path)]) != os.path.abspath(path): + raise aiohttp.web.HTTPNotFound() + + return aiohttp.web.FileResponse(html_path) return _nextjs_handler @@ -501,6 +515,11 @@ def register_nextjs_routes(app): tail_path = page_path + '{tail:.*}' app.router.add_get(tail_path, create_nextjs_handler(full_path)) + # Some exported apps resolve local assets under an `/_next` prefix. + # Mirror those requests back to the same exported directory. + next_prefixed_tail_path = f'/_next{page_path}' + '{tail:.*}' + app.router.add_get(next_prefixed_tail_path, create_nextjs_handler(full_path)) + def register_wsgi_routes(app): ''' diff --git a/modules/portfolio_diff/README.md b/modules/portfolio_diff/README.md index 66bb426f..daaf0981 100644 --- a/modules/portfolio_diff/README.md +++ b/modules/portfolio_diff/README.md @@ -1,36 +1,46 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# portfolio_diff frontend -## Getting Started +This directory contains the Next.js frontend for portfolio diff visualizations. The app is configured with `output: "export"`, so a production build generates static files in `out/` that can be copied into Python modules or archived for an external assets repository. -First, run the development server: +## Prerequisites + +- Node.js + npm available locally. +- Install dependencies from this directory: + +```bash +npm install +``` + +## Build the static site + +From `modules/portfolio_diff`: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +rm -rf out/ +npm run build ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +After build completion, the static export is written to `out/`. -You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. +## Bundle build output into a `.tar.gz` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +If you need an artifact for an assets repository, archive the exported files from `out/`: -## Learn More +```bash +VERSION_TAG=$(date +%Y%m%d-%H%M%S) +tar -C out -czf "portfolio_diff_${VERSION_TAG}.tar.gz" . +``` -To learn more about Next.js, take a look at the following resources: +That creates a tarball in `modules/portfolio_diff` (for example `portfolio_diff-20260710-153010.tar.gz`) containing the static site root. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +You should include copy this to the [`lo_assets` repository](github.com/ETS-Next-Gen/lo_assets) and update the `portfolio_diff-current.tar.gz` link to point to the new version. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Existing helper script -## Deploy on Vercel +This repo also includes `build_and_add_to_module.sh`, which builds and copies `out/` into `../wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/` for local module packaging: -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +```bash +./build_and_add_to_module.sh +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Use that helper when updating the in-repo Python package. Use the tarball workflow above when publishing to a separate assets repository. diff --git a/modules/portfolio_diff/next.config.mjs b/modules/portfolio_diff/next.config.mjs index 0f9816ce..bfb66e10 100644 --- a/modules/portfolio_diff/next.config.mjs +++ b/modules/portfolio_diff/next.config.mjs @@ -1,6 +1,11 @@ /** @type {import('next').NextConfig} */ +const basePath = "/_next/wo_portfolio_diff/portfolio_diff"; + const nextConfig = { - basePath: "/_next/wo_portfolio_diff/portfolio_diff", + basePath, + env: { + NEXT_PUBLIC_BASE_PATH: basePath, + }, eslint: { ignoreDuringBuilds: true, }, diff --git a/modules/portfolio_diff/src/app/layout.js b/modules/portfolio_diff/src/app/layout.js index a588683e..132deb55 100644 --- a/modules/portfolio_diff/src/app/layout.js +++ b/modules/portfolio_diff/src/app/layout.js @@ -1,5 +1,6 @@ import Navbar from "./components/Navbar"; import { CourseIdProvider } from "@/app/providers/CourseIdProvider"; +import Script from "next/script"; import "./globals.css"; export const metadata = { @@ -11,6 +12,12 @@ export default function RootLayout({ children }) { return ( + +