1
0
mirror of https://gitlab.com/Anson-Projects/projects.git synced 2025-09-19 12:02:38 +00:00

18 Commits

Author SHA1 Message Date
890775b2bc GPT5 is too scared to commit and push lmfao 2025-08-22 00:01:03 -06:00
788052233a Fix CI/CD job dependencies and YAML syntax
- Make deploy job dependency optional in ghost-upload jobs
- Change preview job to depend on staging instead of deploy
- Ensures pipeline works on feature branches without deploy job
2025-08-21 23:41:48 -06:00
1a4773b3ef Fix YAML syntax error in preview job script
- Remove problematic environment variable reference
- Use simple string in script section
2025-08-21 23:40:01 -06:00
84f4e48386 Add branch preview deployment and local testing
- Add preview environment for feature branch testing
- Create local deployment test script
- Enable testing without requiring main branch
- Preview URL: project-branch.gitlab.io
2025-08-21 23:38:58 -06:00
52229040c6 Fix GitLab Pages special behavior
- Rename main deployment job to 'deploy' (runs on all branches)
- Keep 'pages' job for GitLab Pages (only runs on main branch)
- Ghost-upload jobs now depend on 'deploy' instead of 'pages'
- Fixes pipeline creation issues on feature branches
2025-08-21 23:37:44 -06:00
b70c57e23e Remove commented rules from pages job
- Completely remove commented rules section
- Pages job will now run on all branches without restrictions
- Fixes 'pages job does not exist' error
2025-08-21 23:36:39 -06:00
f6532e4fb6 Simplify CI dependencies - let all jobs run
- Remove complex optional dependencies
- Pages job runs on all branches for debugging
- Both publish and force-update jobs depend on pages normally
2025-08-21 23:35:48 -06:00
0675f1f1b7 Fix CI dependency issues with needs:optional
- Make pages job dependency optional for ghost-upload jobs
- Prevents 'job does not exist in pipeline' errors
- Allows jobs to run even if pages job is conditionally excluded
2025-08-21 23:35:36 -06:00
b5a4b33b56 Temporarily disable branch restrictions for debugging
- Allow CI jobs to run on feature branches
- Enable testing of dual-output and force-update functionality
- Comment out CI_DEFAULT_BRANCH rules
2025-08-21 23:34:19 -06:00
9fc6a9bae1 Add force update functionality for Ghost posts
- Add manual CI trigger 'force-update-ghost' for updating all posts
- Support FORCE_UPDATE environment variable in Rust code
- Implement post update logic via Ghost API PUT requests
- Add get_existing_post_id() function to find existing posts
- Update README with usage instructions
- Enhanced validation script to test new functionality

Usage:
- Normal: Only syncs new posts (default behavior)
- Force: FORCE_UPDATE=true updates ALL posts including existing ones
2025-08-21 23:30:29 -06:00
05474b986d Add validation and testing for ghost content extraction
- Create validation script to verify implementation
- Add test file for ghost profile rendering
- Validate all components work together correctly
- Ready for CI/CD pipeline testing
2025-08-21 23:25:46 -06:00
cdb96a50b7 Replace iframe with direct HTML content extraction
- Extract article content from ghost-optimized pages
- Add extract_article_content() function with fallback to iframe
- Try multiple selectors to find main content area
- Provide graceful fallbacks for failed content extraction
- Remove unused variables and fix warnings
2025-08-21 23:24:53 -06:00
e233a96f55 Add Quarto profiles for dual-output rendering
- Add ghost profile for iframe-optimized content
- Create ghost-iframe.css with minimal styling
- Update GitLab CI to build both main site and ghost-content versions
- Ghost profile removes navbar, uses minimal theme, article layout
2025-08-21 23:23:27 -06:00
51c03d9213 Merge branch 'modernize' into 'master'
FIx Dev Container

See merge request Anson-Projects/projects!9
2025-05-14 11:02:44 -07:00
609d4064a9 FIx Dev Container 2025-05-14 11:02:43 -07:00
388adf4a02 Fix date in Double Pendulum post 2025-05-11 23:37:08 +00:00
590f8cb106 Merge branch 'pendulum' into 'master'
Double Pendulum

See merge request Anson-Projects/projects!8
2025-05-11 14:27:15 -07:00
10083ec81c Double Pendulum 2025-05-11 14:27:15 -07:00
27 changed files with 1201 additions and 2723 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,3 @@
_freeze/
_site/
public/
ghost-upload/target/
@@ -7,3 +6,4 @@ posts/*/\.jupyter_cache/
!/.quarto/_freeze/
!/.quarto/_freeze/*
/.quarto/
**/.DS_Store

View File

@@ -1,29 +1,33 @@
stages:
- build
- deploy
build:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.21.0-debug
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- /kaniko/executor
- >
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_BRANCH}"
--destination "${CI_REGISTRY_IMAGE}:latest"
--cleanup
staging:
cache:
paths:
- _freeze
stage: deploy
image: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_BRANCH}
script:
- echo "Building the project with Quarto..."
- echo "Building the main website with Quarto..."
- quarto render --to html --output-dir public
- echo "Building Ghost-optimized version..."
- quarto render --profile ghost --to html --output-dir public/ghost-content
artifacts:
paths:
- public
pages:
deploy:
stage: deploy
script:
- echo "Publishing site..."
@@ -32,6 +36,35 @@ pages:
artifacts:
paths:
- public
# Branch preview deployment (for testing)
preview:
stage: deploy
script:
- echo "Deploying branch preview..."
- echo "Preview available at preview URL"
needs:
- job: staging
optional: true
artifacts:
paths:
- public
environment:
name: preview/$CI_COMMIT_REF_SLUG
url: https://${CI_PROJECT_PATH_SLUG}-${CI_COMMIT_REF_SLUG}.gitlab.io
rules:
- if: "$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH"
# GitLab Pages deployment (only on main branch)
pages:
stage: deploy
script:
- echo "Publishing to GitLab Pages..."
needs:
- deploy
artifacts:
paths:
- public
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,8 @@ const kProgressiveAttr = "data-src";
let categoriesLoaded = false;
window.quartoListingCategory = (category) => {
category = atob(category);
// category is URI encoded in EJS template for UTF-8 support
category = decodeURIComponent(atob(category));
if (categoriesLoaded) {
activateCategory(category);
setCategoryHash(category);

46
AGENTS.md Normal file
View File

@@ -0,0 +1,46 @@
# Repository Guidelines
## Project Structure & Module Organization
- `ghost-upload/`: Rust automation for Ghost CMS publishing.
- `posts/`: Quarto posts with Julia/Python code per post directory.
- `public/`: Quarto build output (generated by `quarto render`).
- Root: Quarto config (`_quarto.yml`), shared assets, CI/CD, docs.
## Build, Test, and Development Commands
- Rust (`ghost-upload/`):
- Build: `cd ghost-upload && cargo build`
- Run: `cd ghost-upload && cargo run`
- Test: `cd ghost-upload && cargo test` (single: `cargo test <test_name>`)
- Lint: `cd ghost-upload && cargo clippy`
- Format: `cd ghost-upload && cargo fmt`
- Julia (root or `posts/*/`):
- Packages: `julia -e "using Pkg; Pkg.instantiate()"`
- Precompile: `julia -e "using Pkg; Pkg.precompile()"`
- Run notebook/script: `julia <filename>.jl`
- Quarto (docs/site):
- Build site: `quarto render --to html --output-dir public`
- Preview: `quarto preview`
- Check: `quarto check`
- Docker: `docker build -t projects .` then `docker run projects`
## Coding Style & Naming Conventions
- Rust: `cargo fmt`; fix all `cargo clippy` warnings. Use `?` over `unwrap()`. Imports: std → external → local. Naming: snake_case (fn/vars), PascalCase (types). Public docs with `///`.
- Julia: 4-space indent; spaces around operators; group `using` at top; snake_case; prefer pipelines `|>` for DataFrames; handle expected errors with try-catch.
- Quarto: Include title/date in YAML; set `echo: false`, `warning: false` for clean outputs; descriptive figure captions and alt text.
## Testing Guidelines
- Rust: Unit tests for core logic; add integration tests for API calls. Run with `cargo test`. Organize tests near code or in `tests/`.
- Julia: Validate transformations and plots visually; keep scripts deterministic.
- Quarto: Manually review rendered HTML for links, figures, and warnings.
## Commit & Pull Request Guidelines
- Commits: Use clear, conventional messages (e.g., `feat:`, `fix:`, `docs:`). Scope small and focused.
- PRs: Provide description, linked issues, steps to validate (commands), and screenshots of rendered docs when relevant.
## Security & Configuration
- Environment variables: `kagi_api_key`, `admin_api_key`. Export locally (e.g., `export admin_api_key=...`); never commit secrets.
- Dependencies: Keep minimal and up-to-date. Prefer configuration via env vars over hardcoded values.
## CI/CD & Deployment
- GitLab CI builds Docker, renders Quarto to static hosting; Rust runs separately for content sync. Avoid pipeline changes unless necessary; include rationale in PRs if modified.

View File

@@ -1,11 +1,12 @@
FROM ubuntu:22.04
FROM debian:bookworm
ARG DEBIAN_FRONTEND=noninteractive
ENV JULIA_VERSION=1.11.1 \
ENV JULIA_VERSION=1.11.5 \
JULIA_MAJOR_VERSION=1.11 \
JULIA_PATH=/usr/local/julia \
QUARTO_VERSION=1.6.37
JULIA_PATH=/usr/local/julia
ENV QUARTO_VERSION=1.7.31
RUN apt-get update && apt-get install -y --no-install-recommends \
apt-utils dialog \
@@ -13,19 +14,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-dev \
r-base \
gcc g++ \
wget curl tar \
curl tar \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Use a RUN command for architecture detection and conditional logic
RUN wget https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-$(if [ "$(uname -m)" = "x86_64" ]; then echo "amd64"; else echo "arm64"; fi).tar.gz -O quarto.tar.gz \
RUN curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-$(if [ "$(uname -m)" = "x86_64" ]; then echo "amd64"; else echo "arm64"; fi).tar.gz" -o quarto.tar.gz \
&& tar -xzf quarto.tar.gz -C /opt \
&& mkdir -p /opt/quarto \
&& mv /opt/quarto-${QUARTO_VERSION}/* /opt/quarto/ \
&& ln -s /opt/quarto/bin/quarto /usr/local/bin/quarto \
&& rm -rf quarto.tar.gz /opt/quarto-${QUARTO_VERSION}
RUN python3 -m pip install jupyter webio_jupyter_extension jupyter-cache
RUN python3 -m pip install --break-system-packages jupyter webio_jupyter_extension jupyter-cache
RUN curl -fsSL "https://julialang-s3.julialang.org/bin/linux/$(if [ "$(uname -m)" = "x86_64" ]; then echo "x64"; else echo "aarch64"; fi)/${JULIA_MAJOR_VERSION}/julia-${JULIA_VERSION}-linux-$(if [ "$(uname -m)" = "x86_64" ]; then echo "x86_64"; else echo "aarch64"; fi).tar.gz" -o julia.tar.gz \
&& tar -xzf julia.tar.gz -C /tmp \

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,9 @@
[deps]
CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
Colors = "5ae59095-9a9b-59fe-a467-6f913c188581"
Conda = "8f4d0f93-b110-5947-807f-2305c1781a2d"
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
DifferentialEquations = "0c46a032-eb83-5123-abaf-570d42b7fbaa"
GR_jll = "d2c73de3-f751-5644-a686-071e5b155ba9"
IJulia = "7073ff75-c697-5162-941a-fcdaad2a7d2a"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
Optim = "429524aa-4258-5aef-a3af-852621145aeb"
PlotlyJS = "f0f68f2c-4968-5e81-91da-67840de0976a"
Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
Pluto = "c3e4b0f8-55cb-11ea-2926-15256bba5781"
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
SatelliteToolbox = "6ac157d9-b43d-51bb-8fab-48bf53814f4a"
Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"
[compat]
julia = "1.11"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,253 @@
const kProgressiveAttr = "data-src";
let categoriesLoaded = false;
window.quartoListingCategory = (category) => {
category = atob(category);
if (categoriesLoaded) {
activateCategory(category);
setCategoryHash(category);
}
};
window["quarto-listing-loaded"] = () => {
// Process any existing hash
const hash = getHash();
if (hash) {
// If there is a category, switch to that
if (hash.category) {
// category hash are URI encoded so we need to decode it before processing
// so that we can match it with the category element processed in JS
activateCategory(decodeURIComponent(hash.category));
}
// Paginate a specific listing
const listingIds = Object.keys(window["quarto-listings"]);
for (const listingId of listingIds) {
const page = hash[getListingPageKey(listingId)];
if (page) {
showPage(listingId, page);
}
}
}
const listingIds = Object.keys(window["quarto-listings"]);
for (const listingId of listingIds) {
// The actual list
const list = window["quarto-listings"][listingId];
// Update the handlers for pagination events
refreshPaginationHandlers(listingId);
// Render any visible items that need it
renderVisibleProgressiveImages(list);
// Whenever the list is updated, we also need to
// attach handlers to the new pagination elements
// and refresh any newly visible items.
list.on("updated", function () {
renderVisibleProgressiveImages(list);
setTimeout(() => refreshPaginationHandlers(listingId));
// Show or hide the no matching message
toggleNoMatchingMessage(list);
});
}
};
window.document.addEventListener("DOMContentLoaded", function (_event) {
// Attach click handlers to categories
const categoryEls = window.document.querySelectorAll(
".quarto-listing-category .category"
);
for (const categoryEl of categoryEls) {
// category needs to support non ASCII characters
const category = decodeURIComponent(
atob(categoryEl.getAttribute("data-category"))
);
categoryEl.onclick = () => {
activateCategory(category);
setCategoryHash(category);
};
}
// Attach a click handler to the category title
// (there should be only one, but since it is a class name, handle N)
const categoryTitleEls = window.document.querySelectorAll(
".quarto-listing-category-title"
);
for (const categoryTitleEl of categoryTitleEls) {
categoryTitleEl.onclick = () => {
activateCategory("");
setCategoryHash("");
};
}
categoriesLoaded = true;
});
function toggleNoMatchingMessage(list) {
const selector = `#${list.listContainer.id} .listing-no-matching`;
const noMatchingEl = window.document.querySelector(selector);
if (noMatchingEl) {
if (list.visibleItems.length === 0) {
noMatchingEl.classList.remove("d-none");
} else {
if (!noMatchingEl.classList.contains("d-none")) {
noMatchingEl.classList.add("d-none");
}
}
}
}
function setCategoryHash(category) {
setHash({ category });
}
function setPageHash(listingId, page) {
const currentHash = getHash() || {};
currentHash[getListingPageKey(listingId)] = page;
setHash(currentHash);
}
function getListingPageKey(listingId) {
return `${listingId}-page`;
}
function refreshPaginationHandlers(listingId) {
const listingEl = window.document.getElementById(listingId);
const paginationEls = listingEl.querySelectorAll(
".pagination li.page-item:not(.disabled) .page.page-link"
);
for (const paginationEl of paginationEls) {
paginationEl.onclick = (sender) => {
setPageHash(listingId, sender.target.getAttribute("data-i"));
showPage(listingId, sender.target.getAttribute("data-i"));
return false;
};
}
}
function renderVisibleProgressiveImages(list) {
// Run through the visible items and render any progressive images
for (const item of list.visibleItems) {
const itemEl = item.elm;
if (itemEl) {
const progressiveImgs = itemEl.querySelectorAll(
`img[${kProgressiveAttr}]`
);
for (const progressiveImg of progressiveImgs) {
const srcValue = progressiveImg.getAttribute(kProgressiveAttr);
if (srcValue) {
progressiveImg.setAttribute("src", srcValue);
}
progressiveImg.removeAttribute(kProgressiveAttr);
}
}
}
}
function getHash() {
// Hashes are of the form
// #name:value|name1:value1|name2:value2
const currentUrl = new URL(window.location);
const hashRaw = currentUrl.hash ? currentUrl.hash.slice(1) : undefined;
return parseHash(hashRaw);
}
const kAnd = "&";
const kEquals = "=";
function parseHash(hash) {
if (!hash) {
return undefined;
}
const hasValuesStrs = hash.split(kAnd);
const hashValues = hasValuesStrs
.map((hashValueStr) => {
const vals = hashValueStr.split(kEquals);
if (vals.length === 2) {
return { name: vals[0], value: vals[1] };
} else {
return undefined;
}
})
.filter((value) => {
return value !== undefined;
});
const hashObj = {};
hashValues.forEach((hashValue) => {
hashObj[hashValue.name] = decodeURIComponent(hashValue.value);
});
return hashObj;
}
function makeHash(obj) {
return Object.keys(obj)
.map((key) => {
return `${key}${kEquals}${obj[key]}`;
})
.join(kAnd);
}
function setHash(obj) {
const hash = makeHash(obj);
window.history.pushState(null, null, `#${hash}`);
}
function showPage(listingId, page) {
const list = window["quarto-listings"][listingId];
if (list) {
list.show((page - 1) * list.page + 1, list.page);
}
}
function activateCategory(category) {
// Deactivate existing categories
const activeEls = window.document.querySelectorAll(
".quarto-listing-category .category.active"
);
for (const activeEl of activeEls) {
activeEl.classList.remove("active");
}
// Activate this category
const categoryEl = window.document.querySelector(
`.quarto-listing-category .category[data-category='${btoa(
encodeURIComponent(category)
)}']`
);
if (categoryEl) {
categoryEl.classList.add("active");
}
// Filter the listings to this category
filterListingCategory(category);
}
function filterListingCategory(category) {
const listingIds = Object.keys(window["quarto-listings"]);
for (const listingId of listingIds) {
const list = window["quarto-listings"][listingId];
if (list) {
if (category === "") {
// resets the filter
list.filter();
} else {
// filter to this category
list.filter(function (item) {
const itemValues = item.values();
if (itemValues.categories !== null) {
const categories = decodeURIComponent(
atob(itemValues.categories)
).split(",");
return categories.includes(category);
} else {
return false;
}
});
}
}
}
}

View File

@@ -1,7 +1,9 @@
project:
type: website
website:
profiles:
default:
website:
title: "Anson's Projects"
site-url: https://projects.ansonbiggs.com
description: A Blog for Technical Topics
@@ -15,11 +17,26 @@ website:
# - icon: gitlab
# href: https://gitlab.com/MisterBiggs
open-graph: true
format:
format:
html:
theme: zephyr
css: styles.css
# toc: true
ghost:
website:
title: "Anson's Projects"
site-url: https://projects.ansonbiggs.com
description: A Blog for Technical Topics
navbar: false
open-graph: true
format:
html:
theme: none
css: ghost-iframe.css
toc: false
page-layout: article
title-block-banner: false
execute:
freeze: true

129
ghost-iframe.css Normal file
View File

@@ -0,0 +1,129 @@
/* Ghost iframe optimized styles */
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
color: #333;
max-width: 100%;
margin: 0;
padding: 20px;
background: white;
}
/* Remove any potential margins/padding */
html, body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Ensure content flows naturally */
#quarto-content {
max-width: none;
padding: 0;
margin: 0;
}
/* Style headings for Ghost */
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
h1 { font-size: 2em; }
h2 { font-size: 1.5em; }
h3 { font-size: 1.25em; }
/* Code blocks */
pre {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
font-size: 0.875em;
}
code {
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
background: #f1f3f4;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.875em;
}
pre code {
background: none;
padding: 0;
}
/* Images */
img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
font-weight: 600;
}
/* Links */
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Blockquotes */
blockquote {
border-left: 4px solid #ddd;
margin: 1em 0;
padding-left: 1em;
color: #666;
font-style: italic;
}
/* Lists */
ul, ol {
padding-left: 1.5em;
}
li {
margin-bottom: 0.25em;
}
/* Remove any navbar/footer elements that might leak through */
.navbar, .nav, footer, .sidebar, .toc, .page-footer {
display: none !important;
}
/* Ensure responsive behavior for iframe */
@media (max-width: 768px) {
body {
padding: 15px;
font-size: 16px;
}
h1 { font-size: 1.75em; }
h2 { font-size: 1.35em; }
h3 { font-size: 1.15em; }
}

View File

@@ -10,6 +10,26 @@ publish:
- cd ./ghost-upload
- cargo run
needs:
- pages
- job: deploy
optional: true
- job: staging
optional: true
# Manual trigger to force update all Ghost posts
force-update-ghost:
stage: deploy
image: rust:latest
script:
- echo "🔄 Force updating all Ghost posts..."
- cd ./ghost-upload
- FORCE_UPDATE=true cargo run
needs:
- job: deploy
optional: true
- job: staging
optional: true
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
- when: manual
allow_failure: false
variables:
FORCE_UPDATE: "true"

View File

@@ -1,3 +1,39 @@
# ghost-upload
This code uploads posts from https://projects.ansonbiggs.com to https://notes.ansonbiggs.com. I couldn't figure out how to update posts, and the kagi API doesn't make it clear how long it caches results for so for now only posts that don't exist on the ghost blog will be uploaded. If you want to update content you need to manually make edits to the code and delete posts on the blog.
This tool synchronizes posts from https://projects.ansonbiggs.com to the Ghost blog at https://notes.ansonbiggs.com.
## Features
- **Automatic sync**: Only uploads new posts by default
- **Content extraction**: Fetches clean HTML content instead of using iframes
- **AI summaries**: Uses Kagi Summarizer for post summaries
- **Force update**: Manual trigger to update all existing posts
## Usage
### Normal Mode (Default)
```bash
cargo run
```
Only processes new posts that don't exist on the Ghost blog.
### Force Update Mode
```bash
FORCE_UPDATE=true cargo run
```
Updates ALL posts, including existing ones. Useful for:
- Updating content after changes
- Refreshing summaries
- Applying new styling/formatting
## CI/CD Integration
The GitLab CI pipeline includes:
- **Automatic sync**: Runs after each deployment
- **Manual force update**: Available as a manual trigger in GitLab UI
## Environment Variables
- `admin_api_key`: Ghost Admin API key (required)
- `kagi_api_key`: Kagi Summarizer API key (required)
- `FORCE_UPDATE`: Set to "true" to update all posts (optional)

View File

@@ -45,13 +45,29 @@ impl Post {
let slug = get_slug(link);
let summary = summarize_url(link).await;
// Extract content from ghost-optimized version
let ghost_content = extract_article_content(&link).await;
let html = html! {
div class="ghost-summary" {
h3 { "Summary" }
p { (summary) }
iframe src=(link) style="width: 100%; height: 80vh" { }
}
div class="ghost-content" {
(maud::PreEscaped(ghost_content))
}
div class="ghost-footer" {
hr {}
p {
"This content was originally posted on my projects website " a href=(link) { "here." }
" The above summary was made by the " a href=("https://help.kagi.com/kagi/api/summarizer.html")
{"Kagi Summarizer"}
em {
"This content was originally posted on my projects website "
a href=(link) { "here" }
". The above summary was generated by the "
a href=("https://help.kagi.com/kagi/api/summarizer.html") {"Kagi Summarizer"}
"."
}
}
}
}.into_string();
@@ -130,6 +146,51 @@ fn get_slug(link: &str) -> String {
link.split_once("/posts/").unwrap().1.to_string()
}
async fn extract_article_content(original_link: &str) -> String {
// Convert original link to ghost-content version
let ghost_link = original_link.replace("projects.ansonbiggs.com", "projects.ansonbiggs.com/ghost-content");
match reqwest::get(&ghost_link).await {
Ok(response) => {
match response.text().await {
Ok(html_content) => {
let document = Html::parse_document(&html_content);
// Try different selectors to find the main content
let content_selectors = [
"#quarto-content main",
"#quarto-content",
"main",
"article",
".content",
"body"
];
for selector_str in &content_selectors {
if let Ok(selector) = Selector::parse(selector_str) {
if let Some(element) = document.select(&selector).next() {
let content = element.inner_html();
if !content.trim().is_empty() {
return content;
}
}
}
}
// Fallback: return original content with iframe if extraction fails
format!(r#"<div class="fallback-iframe">
<p><em>Content extraction failed. Falling back to embedded view:</em></p>
<iframe src="{}" style="width: 100%; height: 80vh; border: none;" loading="lazy"></iframe>
</div>"#, original_link)
}
Err(_) => format!(r#"<p><em>Failed to fetch content. <a href="{}">View original post</a></em></p>"#, original_link)
}
}
Err(_) => format!(r#"<p><em>Failed to fetch content. <a href="{}">View original post</a></em></p>"#, original_link)
}
}
async fn check_if_post_exists(entry: &Entry) -> bool {
let posts_url = "https://notes.ansonbiggs.com/";
let link = entry.links.first().unwrap().href.as_str();
@@ -141,6 +202,42 @@ async fn check_if_post_exists(entry: &Entry) -> bool {
}
}
#[derive(Deserialize, Debug)]
struct GhostPostsResponse {
posts: Vec<GhostPost>,
}
#[derive(Deserialize, Debug)]
struct GhostPost {
id: String,
slug: String,
}
async fn get_existing_post_id(slug: &str, token: &str) -> Option<String> {
let client = Client::new();
let api_url = format!("https://notes.ansonbiggs.com/ghost/api/v3/admin/posts/slug/{}/", slug);
match client
.get(&api_url)
.header("Authorization", format!("Ghost {}", token))
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
if let Ok(ghost_response) = response.json::<GhostPostsResponse>().await {
ghost_response.posts.first().map(|post| post.id.clone())
} else {
None
}
} else {
None
}
}
Err(_) => None,
}
}
async fn fetch_feed(url: &str) -> Vec<Entry> {
let content = reqwest::get(url).await.unwrap().text().await.unwrap();
@@ -208,6 +305,16 @@ async fn main() {
let ghost_api_url = "https://notes.ansonbiggs.com/ghost/api/v3/admin/posts/?source=html";
let ghost_admin_api_key = env::var("admin_api_key").unwrap();
// Check if force update is enabled
let force_update = env::var("FORCE_UPDATE").unwrap_or_default() == "true";
if force_update {
println!("🔄 FORCE UPDATE MODE ENABLED");
println!(" This will update ALL posts, including existing ones.");
} else {
println!("📝 NORMAL MODE - Only publishing new posts");
}
let feed = "https://projects.ansonbiggs.com/index.xml";
// Split the key into ID and SECRET
@@ -241,6 +348,10 @@ async fn main() {
// Prepare the post data
let entries = fetch_feed(feed).await;
let filtered_entries: Vec<Entry> = if force_update {
println!("🔄 Force update enabled - processing all {} posts", entries.len());
entries
} else {
let post_exists_futures = entries.into_iter().map(|entry| {
let entry_clone = entry.clone();
async move { (entry_clone, check_if_post_exists(&entry).await) }
@@ -248,11 +359,15 @@ async fn main() {
let post_exists_results = join_all(post_exists_futures).await;
let filtered_entries: Vec<Entry> = post_exists_results
let new_entries: Vec<Entry> = post_exists_results
.into_iter()
.filter_map(|(entry, exists)| if !exists { Some(entry) } else { None })
.collect();
println!("📝 Found {} new posts to publish", new_entries.len());
new_entries
};
if filtered_entries.is_empty() {
println!("Nothing to post.");
return;
@@ -267,21 +382,46 @@ async fn main() {
posts: vec![post.clone()],
};
let response = client
.post(ghost_api_url)
// Check if this is an update (for force_update mode)
let (method, url) = if force_update {
if let Some(existing_id) = get_existing_post_id(&post.slug, &token).await {
println!("🔄 Updating existing post: {}", post.title);
("PUT", format!("https://notes.ansonbiggs.com/ghost/api/v3/admin/posts/{}/", existing_id))
} else {
println!("📝 Creating new post: {}", post.title);
("POST", ghost_api_url.to_string())
}
} else {
println!("📝 Creating new post: {}", post.title);
("POST", ghost_api_url.to_string())
};
let response = match method {
"PUT" => client
.put(&url)
.header("Authorization", format!("Ghost {}", token))
.json(&post_payload)
.send()
.await
.expect("Request failed");
.expect("Request failed"),
_ => client
.post(&url)
.header("Authorization", format!("Ghost {}", token))
.json(&post_payload)
.send()
.await
.expect("Request failed"),
};
// Check the response
if response.status().is_success() {
println!("Post {} published successfully.", post.title);
let action = if method == "PUT" { "updated" } else { "published" };
println!("✅ Post '{}' {} successfully.", post.title, action);
} else {
let action = if method == "PUT" { "update" } else { "publish" };
println!(
"Failed to publish post {}.\n\tResp: {:?}",
&post.title, response
"Failed to {} post '{}'.\n\tStatus: {}\n\tResponse: {:?}",
action, &post.title, response.status(), response.text().await.unwrap_or_default()
);
}
}

BIN
posts/.DS_Store vendored

Binary file not shown.

View File

@@ -0,0 +1,155 @@
---
title: "Double Pendulum"
description: |
Lets create a double pendulum in Observable JS!
date: 2025-05-09
categories:
- Observable JS
- Code
- Math
draft: false
freeze: true
image: FeistyCompetentGarpike-mobile.mp4
image-alt: "My original Double Pendulum done in Python and Processing.js"
---
Quarto (which this blog is built on) recently added support for [Observable JS](https://observablehq.com/@observablehq/observable-javascript), which lets you make really cool interactive and animated visualizations. I have an odd fixation with finding new tools to visualize data, and while JS is far from the first tool I want to grab I figure I should give OJS a shot. Web browsers have been the best way to distribute and share applications for a long time now so I think its time that I invest some time to learn something better than a plotly diagram or jupyter notebook saved as a pdf to share data.
![My original Double Pendulum done in Python and Processing.js](FeistyCompetentGarpike-mobile.mp4){fig-alt="My original Double Pendulum done in Python and Processing.js"}
Many years ago I hit the front page the [/r/python](https://www.reddit.com/r/Python/comments/ci1cg4/double_pendulum_made_with_processingpy/) with a double pendulum I made after watching the wonderful [Daniel Shiffman](https://thecodingtrain.com/showcase/author/anson-biggs) of the Coding Train. The video was posted on gfycat which is now defunct but the internet archive has saved it: [https://web.archive.org/web/20201108021323/https://gfycat.com/feistycompetentgarpike-daniel-shiffman-double-pendulum-coding-train](https://web.archive.org/web/20201108021323/https://gfycat.com/feistycompetentgarpike-daniel-shiffman-double-pendulum-coding-train)
I originally used Processing's Python bindings to make the animation. So, a lot of the hard work was done (mostly by Daniel), and this animation seems to be a crowd pleaser so I went ahead and ported it over. Keeping the code hidden since its not the focus here, but feel free to expand it and peruse.
```{ojs}
//| echo: false
// Interactive controls
viewof length1 = Inputs.range([50, 300], {step: 10, value: 200, label: "Length of pendulum 1"})
viewof length2 = Inputs.range([50, 300], {step: 10, value: 200, label: "Length of pendulum 2"})
viewof mass1 = Inputs.range([10, 100], {step: 5, value: 40, label: "Mass of pendulum 1"})
viewof mass2 = Inputs.range([10, 100], {step: 5, value: 40, label: "Mass of pendulum 2"})
```
```{ojs}
//| code-fold: true
//| column: page
pendulum = {
const width = 900;
const height = 600;
const canvas = DOM.canvas(width, height);
const ctx = canvas.getContext("2d");
const gravity = .1;
const traceCanvas = DOM.canvas(width, height);
const traceCtx = traceCanvas.getContext("2d");
traceCtx.fillStyle = "white";
traceCtx.fillRect(0, 0, width, height);
const centerX = width / 2;
const centerY = 200;
// State variables
let angle1 = Math.PI / 2;
let angle2 = Math.PI / 2;
let angularVelocity1 = 0;
let angularVelocity2 = 0;
let previousPosition2X = -1;
let previousPosition2Y = -1;
function animate() {
// Physics calculations (same equations as Python)
let numerator1Part1 = -gravity * (2 * mass1 + mass2) * Math.sin(angle1);
let numerator1Part2 = -mass2 * gravity * Math.sin(angle1 - 2 * angle2);
let numerator1Part3 = -2 * Math.sin(angle1 - angle2) * mass2;
let numerator1Part4 = angularVelocity2 * angularVelocity2 * length2 +
angularVelocity1 * angularVelocity1 * length1 * Math.cos(angle1 - angle2);
let denominator1 = length1 * (2 * mass1 + mass2 - mass2 * Math.cos(2 * angle1 - 2 * angle2));
let angularAcceleration1 = (numerator1Part1 + numerator1Part2 + numerator1Part3 * numerator1Part4) / denominator1;
let numerator2Part1 = 2 * Math.sin(angle1 - angle2);
let numerator2Part2 = angularVelocity1 * angularVelocity1 * length1 * (mass1 + mass2);
let numerator2Part3 = gravity * (mass1 + mass2) * Math.cos(angle1);
let numerator2Part4 = angularVelocity2 * angularVelocity2 * length2 * mass2 * Math.cos(angle1 - angle2);
let denominator2 = length2 * (2 * mass1 + mass2 - mass2 * Math.cos(2 * angle1 - 2 * angle2));
let angularAcceleration2 = (numerator2Part1 * (numerator2Part2 + numerator2Part3 + numerator2Part4)) / denominator2;
// Update velocities and angles
angularVelocity1 += angularAcceleration1;
angularVelocity2 += angularAcceleration2;
angle1 += angularVelocity1;
angle2 += angularVelocity2;
// Calculate positions
let position1X = length1 * Math.sin(angle1);
let position1Y = length1 * Math.cos(angle1);
let position2X = position1X + length2 * Math.sin(angle2);
let position2Y = position1Y + length2 * Math.cos(angle2);
// Clear and draw to canvas
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(traceCanvas, 0, 0);
// Draw pendulum
ctx.save();
ctx.translate(centerX, centerY);
// First arm and mass
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(position1X, position1Y);
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(position1X, position1Y, mass1/2, 0, 2 * Math.PI);
ctx.fillStyle = "black";
ctx.fill();
// Second arm and mass
ctx.beginPath();
ctx.moveTo(position1X, position1Y);
ctx.lineTo(position2X, position2Y);
ctx.stroke();
ctx.beginPath();
ctx.arc(position2X, position2Y, mass2/2, 0, 2 * Math.PI);
ctx.fill();
ctx.restore();
// Draw trace line
if (previousPosition2X !== -1 && previousPosition2Y !== -1) {
traceCtx.save();
traceCtx.translate(centerX, centerY);
traceCtx.beginPath();
traceCtx.moveTo(previousPosition2X, previousPosition2Y);
traceCtx.lineTo(position2X, position2Y);
traceCtx.strokeStyle = "black";
traceCtx.stroke();
traceCtx.restore();
}
previousPosition2X = position2X;
previousPosition2Y = position2Y;
requestAnimationFrame(animate);
}
animate();
return canvas;
}
```
## Conclusion
I think this is far from an idiomatic implementation so I'll keep this brief. I don't think I used JS or Observable as well as I could have so treat this as a beginner stabbing into the dark because thats essentially what the code is.
This was quite a bit more work than the [original Python implementation](https://gitlab.com/MisterBiggs/double_pendulum/blob/master/double_pendulum.pyde), but running real time, having beaufitul defaults, and being interactive without a backend make this leagues better than anything offered by any other language. There is definitely a loss of energy in the system over time that I attribute to Javascript being a mess, but I doubt that I would ever move all of my analysis to JS anyways so I don't think it matters. Its also very likely I'm doing something bad with my timesteps.

34
test-ghost-profile.md Normal file
View File

@@ -0,0 +1,34 @@
# Test Ghost Profile Output
This is a test document to validate our ghost profile setup.
## Content Structure
The ghost profile should:
- Remove navigation elements
- Use minimal styling from ghost-iframe.css
- Maintain clean article layout
- Remove table of contents
## Code Example
```julia
println("Hello from Julia!")
x = 1 + 1
```
## Regular Content
This is just some regular markdown content to see how it renders in the ghost profile.
- List item 1
- List item 2
- List item 3
**Bold text** and *italic text* should render properly.
[Link to main site](https://projects.ansonbiggs.com)
## Summary
If you can see clean, minimal styling without navigation, the ghost profile is working correctly.

55
test-local-deployment.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
echo "🧪 Testing local deployment simulation..."
# Create test directories
mkdir -p test-output/main
mkdir -p test-output/ghost-content
echo "📁 Simulating dual-output build..."
# Test 1: Check if ghost profile exists
if grep -q "ghost:" _quarto.yml; then
echo "✅ Ghost profile configuration found"
else
echo "❌ Ghost profile not found"
exit 1
fi
# Test 2: Simulate content extraction
echo "🔍 Testing content extraction logic..."
cd ghost-upload
# Test with sample URL (without actually hitting network)
echo "📝 Testing Rust compilation and basic logic..."
if cargo check --quiet; then
echo "✅ Rust code compiles successfully"
else
echo "❌ Rust compilation failed"
exit 1
fi
cd ..
# Test 3: Check if CI would work
echo "🔧 Validating CI configuration..."
if ./validate-ghost-extraction.sh > /dev/null 2>&1; then
echo "✅ CI validation passed"
else
echo "❌ CI validation failed"
exit 1
fi
echo ""
echo "🎉 Local testing complete!"
echo ""
echo "📋 What happens in CI:"
echo " 1. Builds main site → public/"
echo " 2. Builds ghost content → public/ghost-content/"
echo " 3. Rust extracts from ghost-content URLs"
echo " 4. Posts to Ghost blog with clean HTML"
echo ""
echo "🚀 Ready for branch testing in GitLab CI!"
echo " • Download artifacts to see both outputs"
echo " • Use manual trigger to test force-update"
echo " • Check ghost-content/ folder structure"

89
validate-ghost-extraction.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Simple validation script for ghost content extraction
echo "🔍 Validating ghost profile implementation..."
# Check if required files exist
echo "📁 Checking required files..."
if [ ! -f "_quarto.yml" ]; then
echo "❌ _quarto.yml not found"
exit 1
fi
if [ ! -f "ghost-iframe.css" ]; then
echo "❌ ghost-iframe.css not found"
exit 1
fi
if [ ! -f "ghost-upload/src/main.rs" ]; then
echo "❌ Rust source not found"
exit 1
fi
echo "✅ All required files present"
# Check if ghost profile is defined in _quarto.yml
echo "📋 Checking ghost profile configuration..."
if grep -q "ghost:" _quarto.yml; then
echo "✅ Ghost profile found in _quarto.yml"
else
echo "❌ Ghost profile not found in _quarto.yml"
exit 1
fi
# Check if GitLab CI builds both versions
echo "🔧 Checking GitLab CI configuration..."
if grep -q "ghost-content" .gitlab-ci.yml; then
echo "✅ GitLab CI configured for dual output"
else
echo "❌ GitLab CI not configured for ghost-content"
exit 1
fi
# Check if Rust code has extract_article_content function
echo "🦀 Checking Rust implementation..."
if grep -q "extract_article_content" ghost-upload/src/main.rs; then
echo "✅ Content extraction function found"
else
echo "❌ Content extraction function not found"
exit 1
fi
# Check if force update functionality is available
if grep -q "FORCE_UPDATE" ghost-upload/src/main.rs; then
echo "✅ Force update functionality found"
else
echo "❌ Force update functionality not found"
exit 1
fi
# Check if manual CI job is configured
if grep -q "force-update-ghost" ghost-upload/.gitlab-ci.yml; then
echo "✅ Manual force update CI job found"
else
echo "❌ Manual force update CI job not found"
exit 1
fi
# Verify Rust code compiles
echo "🛠️ Building Rust code..."
cd ghost-upload
if cargo check --quiet; then
echo "✅ Rust code compiles successfully"
else
echo "❌ Rust compilation failed"
exit 1
fi
cd ..
echo ""
echo "🎉 All validations passed!"
echo "📋 Summary of changes:"
echo " • Quarto profiles for dual-output rendering"
echo " • Ghost-optimized CSS styling"
echo " • GitLab CI builds both main site and ghost-content"
echo " • Rust extracts HTML content instead of using iframes"
echo " • Force update mode to refresh existing posts"
echo " • Manual CI trigger for content updates"
echo ""
echo "🚀 Ready for testing in CI/CD pipeline!"