3 Commits
Author SHA1 Message Date
git 51edff809a Release Hudra 0.10 beta 1 2026-07-26 16:02:28 +10:00
git 94a82eac07 docs: add AGENTS instructions 2026-07-26 14:32:55 +10:00
git 7692b1c9dd build: add release script and ignore generated packages 2026-07-26 14:14:05 +10:00
11 changed files with 768 additions and 98 deletions
+2
View File
@@ -18,3 +18,5 @@ build/
*.tmp
*.temp
*.log
release/
+242
View File
@@ -0,0 +1,242 @@
Hudra
Purpose
Hudra is a browser extension that turns structured QLearn lesson pages into a clean, classroom-friendly presentation.
Hudra presents existing QLearn content in a slide-oriented or presentation-oriented format.
It is not a QLearn page editor.
Core product promise
Hudra should allow a teacher to open an existing QLearn lesson and present it clearly without rebuilding the content in separate presentation software.
The original QLearn page remains the source of truth.
Responsibilities
Hudra may:
detect supported QLearn lesson pages;
identify presentation sections from page structure;
interpret supported headings as presentation boundaries;
present page content in a slide-like view;
provide previous and next navigation;
support keyboard navigation;
provide fullscreen or classroom presentation modes;
display progress through the presentation;
preserve embedded content where practical;
provide graceful fallback behaviour for unsupported elements;
restore the normal QLearn page when presentation mode closes;
remember appropriate user preferences.
Product boundaries
Hudra must not:
edit or permanently rewrite QLearn page content;
save changes into the QLearn editor;
become a general page-formatting tool;
add teacher notes to the source page;
become a general distraction-removal extension;
duplicate Pendras editing features;
duplicate Lildras general focus-mode features;
require users to rebuild lessons specifically for Hudra unless a documented structure is necessary.
Relationship to other Napper applications
Lildra
Lildra reduces surrounding QLearn clutter while leaving the content in its original continuous-page form.
Hudra provides a presentation experience.
A feature belongs in Hudra when it is specifically useful while presenting lesson content.
Pendra
Pendra helps teachers edit, structure and enhance QLearn pages.
Hudra may interpret structure created by Pendra, but it must not perform Pendras editing work.
Content interpretation
Hudra should rely on semantic page structure wherever possible.
Preferred signals include:
valid heading hierarchy;
clearly defined content sections;
predictable QLearn content containers;
explicit Hudra-compatible markers only where normal semantic structure is insufficient.
Do not rely unnecessarily on fragile visual selectors or exact class names.
QLearn may change its DOM structure. Detection should therefore be defensive and easy to update.
Behaviour principles
Never permanently alter lesson content.
Presentation mode must be reversible.
Closing Hudra should restore the original page state.
Missing elements must not cause the entire extension to fail.
Unsupported content should remain visible where possible.
Avoid hiding important content silently.
Provide clear feedback when a page cannot be presented reliably.
Do not activate on unrelated pages.
Avoid interfering with normal QLearn controls when Hudra is inactive.
User experience
Hudra should feel calm, predictable and suitable for classroom use.
Prioritise:
large readable content;
clear navigation;
minimal controls;
obvious presentation state;
reliable keyboard interaction;
smooth restoration of the normal page;
minimal setup.
Avoid:
crowded toolbars;
unnecessary configuration;
distracting animation;
ambiguous icons;
features unrelated to presenting content.
Accessibility
Preserve meaningful heading and document structure where practical.
Maintain keyboard navigation.
Ensure controls have accessible names.
Use sufficient colour contrast.
Avoid relying on colour alone to convey state.
Preserve alternative text for images.
Do not remove captions or transcripts from embedded content.
Avoid trapping keyboard focus.
Technical principles
Prefer plain JavaScript, HTML and CSS.
Avoid unnecessary frameworks and dependencies.
Keep the extension independently installable.
Request only the permissions Hudra genuinely needs.
Do not include remote executable code.
Do not include API keys, passwords or private credentials.
Validate DOM elements before accessing them.
Isolate QLearn selectors and detection logic where practical.
Avoid duplicated selectors across multiple files.
Use clear, descriptive names.
Keep functions focused.
Add comments only when behaviour is not obvious.
Remove temporary debugging output before release.
QLearn safety
Hudra operates inside an external platform that may change without notice.
Code must:
check that expected elements exist;
handle missing or changed selectors;
avoid assumptions about a single page layout;
fail without breaking the underlying QLearn page;
avoid deleting source elements;
avoid destructive DOM operations;
retain enough state to restore the original page;
document selectors that are especially fragile.
Permissions and privacy
Hudra should request the smallest possible set of browser permissions.
Any stored data should normally be limited to local preferences, such as:
display options;
presentation preferences;
last-used settings.
Do not collect or transmit:
student information;
lesson content;
QLearn credentials;
browsing history;
analytics;
personal data;
unless the repository documentation and privacy policy are deliberately updated to reflect verified behaviour.
Repository expectations
The repository should contain:
README.md
AGENTS.md
CHANGELOG.md
manifest.json
source files
icons
release tooling
relevant documentation
tests where practical
Generated release packages should not be committed unless there is a deliberate reason.
Release output should remain ignored through .gitignore.
Versioning
Hudra uses its own version number.
Use semantic versioning where practical:
patch: fixes without new user-facing capability;
minor: backwards-compatible features;
major: substantial or potentially disruptive changes.
The version in manifest.json must match the intended release.
Use release tags such as:
v0.9.3
v1.0.0
v1.1.0
Release packages should use names such as:
Hudra-v1.0.0.zip
Release requirements
Before preparing a release:
Confirm the manifest version.
Review requested permissions.
Run available checks and tests.
Test Hudra manually on representative QLearn pages.
Test activation and deactivation.
Test keyboard navigation.
Test fullscreen behaviour if supported.
Confirm the normal QLearn page is restored correctly.
Check the extension package contains only required runtime files.
Exclude development files, previous releases and generated folders.
Update CHANGELOG.md.
Update documentation when behaviour changes.
Identify remaining manual testing requirements.
Codex working rules
When asked to implement a feature:
Inspect the relevant implementation before editing.
Confirm that the feature belongs within Hudras scope.
Prefer the smallest reliable change.
Do not broaden permissions without explaining why.
Do not silently change public behaviour.
Preserve compatibility with existing structured pages where possible.
Update documentation and changelog entries when appropriate.
Run available validation.
Summarise changes clearly.
State what still requires testing inside QLearn.
When asked to review the repository:
distinguish confirmed defects from possible risks;
do not rewrite functioning code solely for stylistic preference;
identify fragile QLearn selectors;
identify destructive or irreversible DOM changes;
identify unnecessary permissions;
identify files accidentally included in release packages;
identify differences between documented and actual behaviour.
+11 -2
View File
@@ -2,6 +2,15 @@
Turn HTML lessons into classroom presentations.
## Hudra 0.10 Beta 1 — 2026-07-26
- Restrict presentation startup to supported QLearn and Instructure lesson pages.
- Show an accessible in-page message when Hudra cannot start.
- Restore active presenter sessions after the extension service worker restarts.
- Remove the unnecessary `tabs` permission.
- Preserve rich paragraph markup during slide pagination.
- Package only browser-extension runtime files in release builds.
Build once.
Present everywhere.
@@ -47,9 +56,9 @@ It simply gives it Presentation Mode.
## Roadmap
Current release
Current beta release
Hudra 0.9.1
Hudra 0.10 Beta 1
See ROADMAP.md
+3
View File
@@ -3,6 +3,9 @@ Hudra
Hudra transforms structured Canvas/QLearn pages into responsive presentations. All lesson processing remains local in the browser.
CURRENT BETA
Hudra 0.10 Beta 1
INSTALL
1. Extract this ZIP file.
2. Open edge://extensions, chrome://extensions or brave://extensions.
+10
View File
@@ -3,6 +3,16 @@
Hudra is developed incrementally, with stability and simplicity prioritised
over large feature releases.
## Current beta release
### Hudra 0.10 Beta 1
- Supported-page detection for QLearn and Canvas
- Resilient presenter controls across service-worker restarts
- Rich paragraph markup preserved during pagination
- Reduced browser permissions
- Runtime-only release packaging
## Current stable release
### Hudra 0.9.1
+171 -1
View File
@@ -2,6 +2,80 @@ const sessions = new Map();
const tabToSession = new Map();
const windowToSession = new Map();
const previousWindowStates = new Map();
const sessionStorageKey = "hudraActiveSessions";
let persistStatePromise = Promise.resolve();
function rebuildSessionIndexes() {
tabToSession.clear();
windowToSession.clear();
sessions.forEach((session) => {
if (session.audienceTabId != null) {
tabToSession.set(session.audienceTabId, session.id);
}
if (session.audienceWindowId != null) {
windowToSession.set(session.audienceWindowId, session.id);
}
if (session.controlWindowId != null) {
windowToSession.set(session.controlWindowId, session.id);
}
});
}
async function restoreSessionState() {
const stored = await chrome.storage.session.get(sessionStorageKey);
const state = stored[sessionStorageKey];
if (!state) return;
for (const storedSession of state.sessions || []) {
if (!storedSession?.id) continue;
sessions.set(storedSession.id, {
...storedSession,
pageTitle: "Hudra",
current: 0,
count: 0,
audienceHidden: false,
audienceZoom: 100,
audienceScrollY: 0,
currentPreview: "",
nextPreview: ""
});
}
for (const [windowId, windowState] of state.previousWindowStates || []) {
previousWindowStates.set(Number(windowId), windowState);
}
rebuildSessionIndexes();
}
const sessionStateReady = restoreSessionState().catch((error) => {
console.warn("Hudra could not restore its active sessions:", error);
});
async function ensureSessionStateReady() {
await sessionStateReady;
}
function persistSessionState() {
const state = {
sessions: [...sessions.values()].map((session) => ({
id: session.id,
mode: session.mode,
sourceTabId: session.sourceTabId,
audienceTabId: session.audienceTabId,
audienceWindowId: session.audienceWindowId,
controlWindowId: session.controlWindowId
})),
previousWindowStates: [...previousWindowStates.entries()]
};
persistStatePromise = persistStatePromise
.catch(() => {})
.then(() => chrome.storage.session.set({ [sessionStorageKey]: state }));
return persistStatePromise;
}
function makeSessionId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
@@ -32,6 +106,57 @@ function chooseSecondaryDisplay(displays) {
return { primary, secondary };
}
function isSupportedLessonUrl(value) {
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
return url.protocol === "https:" && (
hostname === "qlearn.eq.edu.au" ||
hostname.endsWith(".qlearn.eq.edu.au") ||
hostname.endsWith(".instructure.com")
);
} catch (error) {
return false;
}
}
async function showPageNotice(tabId, message) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["page-detection.js"]
});
await chrome.scripting.executeScript({
target: { tabId },
func: (noticeMessage) => window.__hudraPageDetector.showNotice(noticeMessage),
args: [message]
});
}
async function inspectLessonPage(tabId) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["page-detection.js"]
});
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => window.__hudraPageDetector.inspect()
});
return results[0]?.result || { supported: false, reason: "INSPECTION_FAILED" };
}
function messageForUnsupportedPage(reason) {
if (reason === "NO_LESSON_TITLE") {
return "Hudra couldnt start because this lesson has no H1 title.";
}
if (reason === "EMPTY_LESSON") {
return "Hudra couldnt start because this lesson has no presentable content.";
}
return "Hudra couldnt start because this page does not contain a supported lesson.";
}
async function waitForTabComplete(tabId, timeoutMs = 20000) {
const existing = await chrome.tabs.get(tabId).catch(() => null);
if (existing?.status === "complete") return;
@@ -57,7 +182,7 @@ async function waitForTabComplete(tabId, timeoutMs = 20000) {
async function injectPresenter(tabId, config) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["presenter.js"]
files: ["page-detection.js", "presenter.js"]
});
await chrome.tabs.sendMessage(tabId, {
@@ -67,6 +192,7 @@ async function injectPresenter(tabId, config) {
}
async function startSingleScreen(tab) {
await ensureSessionStateReady();
if (!tab.id || tab.windowId == null) return;
const sessionId = makeSessionId();
@@ -93,12 +219,14 @@ async function startSingleScreen(tab) {
sessions.set(sessionId, session);
tabToSession.set(tab.id, sessionId);
windowToSession.set(tab.windowId, sessionId);
await persistSessionState();
await injectPresenter(tab.id, { mode: "single", sessionId });
await chrome.windows.update(tab.windowId, { state: "fullscreen" });
}
async function startDualScreen(tab, primary, secondary) {
await ensureSessionStateReady();
if (!tab.id || !tab.url) return;
const sessionId = makeSessionId();
@@ -153,6 +281,7 @@ async function startDualScreen(tab, primary, secondary) {
tabToSession.set(audienceTab.id, sessionId);
windowToSession.set(audienceWindow.id, sessionId);
if (controlWindow.id != null) windowToSession.set(controlWindow.id, sessionId);
await persistSessionState();
await waitForTabComplete(audienceTab.id);
await injectPresenter(audienceTab.id, { mode: "audience", sessionId });
@@ -180,6 +309,7 @@ async function startDualScreen(tab, primary, secondary) {
}
async function endSession(sessionId, reason = "ended") {
await ensureSessionStateReady();
const session = sessions.get(sessionId);
if (!session) return;
@@ -197,10 +327,12 @@ async function endSession(sessionId, reason = "ended") {
const previousState = previousWindowStates.get(session.audienceWindowId) || "maximized";
previousWindowStates.delete(session.audienceWindowId);
await persistSessionState();
await chrome.windows.update(session.audienceWindowId, { state: previousState }).catch(async () => {
await chrome.windows.update(session.audienceWindowId, { state: "maximized" }).catch(() => {});
});
} else {
await persistSessionState();
if (session.audienceWindowId != null) {
await chrome.windows.remove(session.audienceWindowId).catch(() => {});
}
@@ -211,6 +343,7 @@ async function endSession(sessionId, reason = "ended") {
}
async function sendCommand(sessionId, command, value) {
await ensureSessionStateReady();
const session = sessions.get(sessionId);
if (!session?.audienceTabId) return;
@@ -227,6 +360,7 @@ async function sendCommand(sessionId, command, value) {
}
async function toggleHudraForTab(tab) {
await ensureSessionStateReady();
if (!tab?.id) return;
const windowSessionId = tab.windowId != null ? windowToSession.get(tab.windowId) : null;
@@ -241,6 +375,30 @@ async function toggleHudraForTab(tab) {
return;
}
if (!isSupportedLessonUrl(tab.url)) {
await showPageNotice(
tab.id,
"Hudra works on QLearn and Canvas lesson pages hosted by Instructure."
).catch(() => {});
return;
}
let inspection;
try {
inspection = await inspectLessonPage(tab.id);
} catch (error) {
await showPageNotice(
tab.id,
"Hudra couldnt inspect this page. Wait for it to finish loading and try again."
).catch(() => {});
return;
}
if (!inspection.supported) {
await showPageNotice(tab.id, messageForUnsupportedPage(inspection.reason)).catch(() => {});
return;
}
try {
const displays = await getUsableDisplays();
const { primary, secondary } = chooseSecondaryDisplay(displays);
@@ -270,6 +428,7 @@ chrome.commands.onCommand.addListener(async (command) => {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === "QLEARN_PRESENTER_STATE") {
ensureSessionStateReady().then(() => {
const session = sessions.get(message.sessionId);
if (!session) return;
@@ -295,6 +454,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
currentPreview: session.currentPreview,
nextPreview: session.nextPreview
}).catch(() => {});
});
return;
}
@@ -309,7 +469,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
if (message?.type === "QLEARN_PRESENTER_GET_SESSION") {
ensureSessionStateReady().then(() => {
const session = sessions.get(message.sessionId);
if (session?.audienceTabId != null) {
chrome.tabs.sendMessage(session.audienceTabId, {
type: "QLEARN_PRESENTER_COMMAND",
command: "reportState"
}).catch(() => {});
}
sendResponse(session ? {
sessionId: session.id,
mode: session.mode,
@@ -322,11 +489,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
currentPreview: session.currentPreview,
nextPreview: session.nextPreview
} : null);
});
return true;
}
});
chrome.windows.onRemoved.addListener((windowId) => {
ensureSessionStateReady().then(() => {
const sessionId = windowToSession.get(windowId);
if (!sessionId) return;
const session = sessions.get(sessionId);
@@ -337,3 +506,4 @@ chrome.windows.onRemoved.addListener((windowId) => {
endSession(sessionId, "window-closed").catch(() => {});
}
});
});
+21 -1
View File
@@ -13,13 +13,28 @@ The current presentation model uses HTML headings:
- H1 identifies the lesson title
- H2 identifies the beginning of a new slide
- content following an H2 belongs to that slide until the next H2
- H3 identifies the beginning of a new slide
- H4 identifies a subsection within the current slide
- content following an H2 or H3 belongs to that slide until the next H2 or H3
Pages without H2 or H3 headings are presented as a single slide.
## Supported pages
Hudra starts only on secure QLearn pages at `qlearn.eq.edu.au` (including
subdomains) and Canvas pages on an `instructure.com` subdomain. A supported
page must also contain a recognised lesson-content container and an H1 lesson
title. Unsupported pages receive a temporary in-page notice and are not put
into presentation mode.
## Main components
### background.js
Handles extension-level behaviour and presentation launch logic.
Active session routing and window-restoration metadata is kept in
`chrome.storage.session` so presenter controls survive service-worker restarts.
Lesson content and slide previews are not persisted.
### controls.html
@@ -37,6 +52,11 @@ Manages presenter navigation, controls and audience communication.
Creates and manages the audience presentation experience.
### page-detection.js
Contains the shared lesson-container and title detection used before launch
and when presentation mode starts.
### manifest.json
Defines the extension metadata, permissions and version.
+3 -2
View File
@@ -1,12 +1,13 @@
{
"manifest_version": 3,
"name": "Hudra",
"version": "0.9.2",
"version": "0.10.0",
"version_name": "0.10 Beta 1",
"description": "Hudra turns Canvas/QLearn pages into responsive presentations with automatic pagination, dual-screen presenter view, audience controls, clicker support and local-only processing.",
"permissions": [
"activeTab",
"scripting",
"tabs",
"storage",
"system.display"
],
"host_permissions": [
+122
View File
@@ -0,0 +1,122 @@
(() => {
if (window.__hudraPageDetector) return;
const contentRootSelectors = [
"#content .user_content",
".show-content .user_content",
".wiki-page-content .user_content",
".user_content",
"[role='main'] .page-content",
"[role='main'] .content"
];
const titleSelectors = [
"h1.page-title",
"h1.wiki-page-title",
"#content h1"
];
function hasMeaningfulContent(element) {
return Boolean(
element?.textContent?.trim() ||
element?.querySelector("img, video, iframe, svg, canvas, picture, table")
);
}
function resolve() {
let contentRoot = null;
let contentRootSelector = null;
for (const selector of contentRootSelectors) {
const candidate = document.querySelector(selector);
if (candidate) {
contentRoot = candidate;
contentRootSelector = selector;
break;
}
}
if (!contentRoot) {
return { supported: false, reason: "NO_LESSON_CONTAINER" };
}
const pageTitleElement =
contentRoot.querySelector("h1") ||
document.querySelector(titleSelectors.join(", "));
if (!pageTitleElement) {
return { supported: false, reason: "NO_LESSON_TITLE" };
}
if (!hasMeaningfulContent(contentRoot)) {
return { supported: false, reason: "EMPTY_LESSON" };
}
return {
supported: true,
reason: null,
contentRoot,
contentRootSelector,
pageTitleElement
};
}
function inspect() {
const result = resolve();
return {
supported: result.supported,
reason: result.reason,
contentRootSelector: result.contentRootSelector || null
};
}
function showNotice(message) {
document.getElementById("hudra-page-notice")?.remove();
const notice = document.createElement("div");
notice.id = "hudra-page-notice";
notice.setAttribute("role", "status");
notice.setAttribute("aria-live", "polite");
notice.style.cssText = [
"position:fixed",
"z-index:2147483647",
"right:20px",
"bottom:20px",
"max-width:420px",
"box-sizing:border-box",
"padding:16px 48px 16px 18px",
"border:1px solid #bac4d2",
"border-radius:10px",
"background:#fff",
"color:#1f2937",
"box-shadow:0 8px 28px rgba(15,23,42,.2)",
"font:16px/1.4 system-ui,sans-serif"
].join(";");
const text = document.createElement("span");
text.textContent = message;
notice.appendChild(text);
const close = document.createElement("button");
close.type = "button";
close.setAttribute("aria-label", "Dismiss Hudra message");
close.textContent = "×";
close.style.cssText = [
"position:absolute",
"top:8px",
"right:10px",
"border:0",
"background:transparent",
"color:#374151",
"font:24px/1 system-ui,sans-serif",
"cursor:pointer"
].join(";");
close.addEventListener("click", () => notice.remove());
notice.appendChild(close);
document.body.appendChild(notice);
window.setTimeout(() => notice.remove(), 8000);
}
window.__hudraPageDetector = { inspect, resolve, showNotice };
})();
+12 -47
View File
@@ -10,36 +10,19 @@
const presenterMode = config.mode || "single";
const sessionId = config.sessionId || "standalone";
const contentRoot =
document.querySelector(
"#content .user_content, " +
".show-content .user_content, " +
".wiki-page-content .user_content, " +
".user_content"
) ||
document.querySelector(
"[role='main'] .page-content, " +
"[role='main'] .content"
) ||
document.querySelector("#content") ||
document.body;
const detectedPage = window.__hudraPageDetector?.resolve();
const pageTitleElement =
contentRoot.querySelector("h1") ||
document.querySelector(
"#content h1, .page-title, .wiki-page-title, h1.page-title"
if (!detectedPage?.supported) {
window.__hudraPageDetector?.showNotice(
"Hudra couldnt start because this page does not contain a supported lesson."
);
return;
}
const { contentRoot, pageTitleElement } = detectedPage;
let pageTitle = pageTitleElement?.textContent?.trim() || "";
if (!pageTitle) {
pageTitle = document.title.replace(/\s*:\s*[^:]+$/, "").trim();
}
if (!pageTitle) {
pageTitle = "Lesson";
}
function isHidden(element) {
if (!element || element.hidden) return true;
@@ -101,11 +84,6 @@
}
}
if (!slideDefinitions.length) {
alert("No presentable page content was found.");
return;
}
const presentationLayer = document.createElement("div");
presentationLayer.id = "qlearn-presenter-layer";
document.body.appendChild(presentationLayer);
@@ -699,20 +677,6 @@
return heading;
}
function splitParagraph(paragraph) {
const text = paragraph.textContent.trim();
const sentences = text.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [text];
if (sentences.length < 2) return [paragraph.cloneNode(true)];
const midpoint = Math.ceil(sentences.length / 2);
return [sentences.slice(0, midpoint), sentences.slice(midpoint)]
.filter((part) => part.length)
.map((part) => {
const copy = paragraph.cloneNode(false);
copy.textContent = part.join(" ").trim();
return copy;
});
}
function splitList(list) {
const items = [...list.children].filter((item) => item.tagName === "LI");
if (items.length < 2) return [list.cloneNode(true)];
@@ -726,9 +690,6 @@
function divideOversizedBlock(block) {
if (block.matches("ul, ol")) return splitList(block);
if (block.matches("p") && block.textContent.trim().length > 220) {
return splitParagraph(block);
}
return [block.cloneNode(true)];
}
@@ -1371,6 +1332,9 @@
},
getSlideCount() {
return slideWrappers.length;
},
reportState() {
sendPresenterState();
}
};
@@ -1399,6 +1363,7 @@
if (command === "scrollBy") window.__qlearnPresenter.scrollBy(message.value);
if (command === "resetView") window.__qlearnPresenter.resetView();
if (command === "goTo") window.__qlearnPresenter.goTo(message.value);
if (command === "reportState") window.__qlearnPresenter.reportState();
if (command === "exit") window.__qlearnPresenter.exit(false);
}
});
+126
View File
@@ -0,0 +1,126 @@
# Hudra release builder
# Reads the version from manifest.json, creates a clean release folder,
# and produces a Chrome Web Store-ready ZIP.
$ErrorActionPreference = "Stop"
# Project locations
$ProjectRoot = $PSScriptRoot
$ManifestPath = Join-Path $ProjectRoot "manifest.json"
$ReleaseRoot = Join-Path $ProjectRoot "release"
# Check that manifest.json exists
if (-not (Test-Path $ManifestPath)) {
Write-Host "ERROR: manifest.json was not found." -ForegroundColor Red
Write-Host "Place release.ps1 in the same folder as manifest.json."
exit 1
}
# Read extension name and version
try {
$Manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json
}
catch {
Write-Host "ERROR: manifest.json is not valid JSON." -ForegroundColor Red
exit 1
}
$ExtensionName = $Manifest.name
$Version = $Manifest.version
if ([string]::IsNullOrWhiteSpace($ExtensionName)) {
$ExtensionName = "Hudra"
}
if ([string]::IsNullOrWhiteSpace($Version)) {
Write-Host "ERROR: No version was found in manifest.json." -ForegroundColor Red
exit 1
}
$SafeName = $ExtensionName -replace '[^a-zA-Z0-9_-]', '-'
$ReleaseName = "$SafeName-v$Version"
$StagingFolder = Join-Path $ReleaseRoot $ReleaseName
$ZipPath = Join-Path $ReleaseRoot "$ReleaseName.zip"
Write-Host ""
Write-Host "Building $ExtensionName $Version..." -ForegroundColor Cyan
Write-Host ""
# Only files required by the browser extension at runtime belong in the ZIP.
# Everything else is excluded by default, including release output, previous
# releases, scripts, repository metadata, documentation and development files.
$IncludedItems = @(
"manifest.json",
"background.js",
"page-detection.js",
"presenter.js",
"controls.html",
"controls.css",
"controls.js",
"icons"
)
# Recreate the staging folder
if (Test-Path $StagingFolder) {
Remove-Item $StagingFolder -Recurse -Force
}
New-Item -ItemType Directory -Path $StagingFolder -Force | Out-Null
# Copy only the allowlisted runtime files into the clean staging folder
$IncludedItems | ForEach-Object {
$SourcePath = Join-Path $ProjectRoot $_
if (-not (Test-Path $SourcePath)) {
Write-Host "ERROR: Required runtime item was not found: $_" -ForegroundColor Red
exit 1
}
Copy-Item `
-Path $SourcePath `
-Destination $StagingFolder `
-Recurse `
-Force
}
# Confirm that the copied release contains manifest.json
$CopiedManifest = Join-Path $StagingFolder "manifest.json"
if (-not (Test-Path $CopiedManifest)) {
Write-Host "ERROR: manifest.json was not copied into the release folder." -ForegroundColor Red
exit 1
}
# Remove any previous ZIP for this version
if (Test-Path $ZipPath) {
Remove-Item $ZipPath -Force
}
# ZIP the contents, ensuring manifest.json is at the ZIP root
$FilesToZip = Join-Path $StagingFolder "*"
Compress-Archive `
-Path $FilesToZip `
-DestinationPath $ZipPath `
-CompressionLevel Optimal `
-Force
# Verify the ZIP was created
if (-not (Test-Path $ZipPath)) {
Write-Host "ERROR: The release ZIP could not be created." -ForegroundColor Red
exit 1
}
$ZipSize = [math]::Round((Get-Item $ZipPath).Length / 1KB, 1)
Write-Host "Release created successfully!" -ForegroundColor Green
Write-Host ""
Write-Host "Release folder:"
Write-Host " $StagingFolder" -ForegroundColor Yellow
Write-Host ""
Write-Host "ZIP file:"
Write-Host " $ZipPath" -ForegroundColor Yellow
Write-Host ""
Write-Host "ZIP size: $ZipSize KB"
Write-Host ""
Write-Host "Ready for Gitea and the Chrome Web Store." -ForegroundColor Green