Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions scripts/fetch-gnome-extensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,23 @@ async function fetchExtensionData(pk) {
return buildExtensionRecord(pk, data, localScreenshot);
}

/**
* Per the repo's data-pipeline rules (AGENTS.md), a fetch script must never
* fail the build: no throw, no non-zero exit, no silently empty file. On
* error it writes this explicit unavailable payload instead, so consumers
* (e.g. GnomeExtensions.tsx) can render a visible reason rather than hang at
* "Loading...".
*/
function unavailablePayload(reason) {
return { unavailable: true, stateReason: reason };
}

function writeUnavailable(reason) {
console.warn(`fetch-gnome-extensions: ${reason} — writing unavailable payload`);
fs.mkdirSync(path.dirname(OUTPUT_JSON), { recursive: true });
fs.writeFileSync(OUTPUT_JSON, JSON.stringify(unavailablePayload(reason), null, 2));
}

async function main() {
if (!isStale(OUTPUT_JSON)) return;

Expand All @@ -140,8 +157,8 @@ async function main() {
}

if (extensions.length === 0) {
console.error("All extension fetches failed — aborting.");
process.exit(1);
writeUnavailable("All GNOME extension fetches failed");
return;
}
if (extensions.length < EXTENSION_IDS.length) {
console.warn(`Warning: only ${extensions.length}/${EXTENSION_IDS.length} extensions fetched.`);
Expand All @@ -152,10 +169,14 @@ async function main() {
}

if (require.main === module) {
main().catch((e) => { console.error(e); process.exit(1); });
main().catch((e) => {
console.error(e);
writeUnavailable(`GNOME extension data could not be generated: ${e.message}`);
});
}

module.exports = {
buildExtensionRecord,
isStale,
unavailablePayload,
};
8 changes: 8 additions & 0 deletions scripts/fetch-gnome-extensions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const path = require("path");
const {
buildExtensionRecord,
isStale,
unavailablePayload,
} = require("./fetch-gnome-extensions.js");

test("buildExtensionRecord normalizes remote GNOME extension fields", () => {
Expand Down Expand Up @@ -41,3 +42,10 @@ test("isStale returns true when the cache file does not exist", () => {
true,
);
});

test("unavailablePayload emits the documented unavailable-object shape", () => {
assert.deepEqual(unavailablePayload("All GNOME extension fetches failed"), {
unavailable: true,
stateReason: "All GNOME extension fetches failed",
});
});
63 changes: 53 additions & 10 deletions src/components/GnomeExtensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,44 +15,87 @@ interface ExtensionData {
donateUrl: string | null;
}

/**
* scripts/fetch-gnome-extensions.js never fails the build: on error it writes
* `{ unavailable: true, stateReason }` instead of the extension array (see
* AGENTS.md → Data pipelines). This must be checked before treating the
* payload as an array, or a plain `.find()` throws.
*/
interface UnavailablePayload {
unavailable: true;
stateReason: string;
}

type ExtensionsResponse = ExtensionData[] | UnavailablePayload;

function isUnavailablePayload(
data: ExtensionsResponse,
): data is UnavailablePayload {
return !Array.isArray(data) && data?.unavailable === true;
}

interface GnomeExtensionsProps {
extensionId: number;
}

type LoadState =
| { status: "loading" }
| { status: "found"; extension: ExtensionData }
| { status: "not-found" }
| { status: "unavailable"; reason: string };

const GnomeExtensions: React.FC<GnomeExtensionsProps> = ({ extensionId }) => {
const [extension, setExtension] = useState<ExtensionData | null>(null);
const [state, setState] = useState<LoadState>({ status: "loading" });
const [imageError, setImageError] = useState(false);
const [loadError, setLoadError] = useState(false);

useEffect(() => {
fetch("/data/gnome-extensions.json")
.then((response) => response.json())
.then((data: ExtensionData[]) => {
const ext = data.find((item) => item.id === extensionId);
if (ext) {
setExtension(ext);
.then((data: ExtensionsResponse) => {
if (isUnavailablePayload(data)) {
setState({ status: "unavailable", reason: data.stateReason });
return;
}
const ext = data.find((item) => item.id === extensionId);
setState(ext ? { status: "found", extension: ext } : { status: "not-found" });
})
.catch((error) => {
console.error("Error loading extension metadata:", error);
setLoadError(true);
setState({
status: "unavailable",
reason: "Extension data could not be loaded.",
});
});
}, [extensionId]);

if (loadError) {
if (state.status === "unavailable") {
return (
<div className={styles.extensionBox}>
<div className={styles.extensionInfo}>
<p className={styles.extensionDescription}>Extension data unavailable.</p>
<p className={styles.extensionDescription}>{state.reason}</p>
</div>
</div>
);
}

if (!extension) {
if (state.status === "not-found") {
return (
<div className={styles.extensionBox}>
<div className={styles.extensionInfo}>
<p className={styles.extensionDescription}>
Extension data unavailable.
</p>
</div>
</div>
);
}

if (state.status === "loading") {
return <div className={styles.extensionBox}>Loading...</div>;
}

const extension = state.extension;

const thumbnailUrl = extension.screenshot || extension.remoteScreenshot;
// Truncate to first line, then cap at 150 chars if still too long
const firstLine = (extension.description ?? "").split("\n")[0];
Expand Down