Skip to content
Merged
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
20 changes: 15 additions & 5 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ jobs:
VAULT_PATH: vault

- name: Validate AI exports
run: deno test -A src/unfold/tests/exporters_ai_test.ts
run: deno test -A src/unfold/exporters/exporters_ai_test.ts
env:
SITE_URL: ${{ steps.site.outputs.site_url }}
SITE_OUTPUT_DIR: .unfold/site
Expand All @@ -78,6 +78,7 @@ jobs:

container-smoke:
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -98,25 +99,33 @@ jobs:

- name: Start Unfold container
run: |
docker run -d --rm -p 3000:3000 \
docker run -d -p 3000:3000 \
-e SITE_URL=http://localhost:3000 \
-e VAULT_PATH=vault \
-v "$PWD/vault:/workspaces/schema-nodes/vault" \
--name unfold-smoke \
unfold-dev-ci:latest
for i in $(seq 1 30); do
running=$(docker inspect -f '{{.State.Running}}' unfold-smoke 2>/dev/null || echo "false")
if [ "$running" != "true" ]; then
echo "Container exited before health check passed."
docker logs unfold-smoke || true
exit 1
fi
curl -fsS http://localhost:3000/healthz >/dev/null 2>&1 && exit 0
sleep 1
done
docker logs unfold-smoke
docker logs unfold-smoke || true
exit 1

- name: Stop Unfold container
if: always()
run: docker stop unfold-smoke
run: docker rm -f unfold-smoke || true

publish-image:
runs-on: ubuntu-latest
needs: container-smoke
needs: [build, container-smoke]
Comment thread
bdelanghe marked this conversation as resolved.
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -139,6 +148,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
Expand Down
2 changes: 2 additions & 0 deletions src/unfold/exporters/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export const buildLlmsTxt = async (
const url = resolveCanonical(page, siteUrl);
const title = normalizeText(
page.document?.querySelector("title")?.textContent,
) || normalizeText(
typeof page.data?.title === "string" ? page.data.title : "",
);
if (!url) {
return null;
Expand Down
4 changes: 4 additions & 0 deletions src/unfold/exporters/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,15 @@ export const buildMcpBundle = async (
const url = resolveCanonical(page, siteUrl);
const title = normalizeText(
page.document?.querySelector("title")?.textContent,
) || normalizeText(
typeof page.data?.title === "string" ? page.data.title : "",
);
const description = normalizeText(
page.document?.querySelector('meta[name="description"]')?.getAttribute(
"content",
),
) || normalizeText(
typeof page.data?.description === "string" ? page.data.description : "",
);
if (!url || !title) {
return null;
Expand Down
2 changes: 1 addition & 1 deletion src/unfold/inputs/vault/load_vault.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { walk } from "@std/fs";
import { relative } from "@std/path";
import { isAbsolute, join, relative, toFileUrl } from "@std/path";
import { vaultConfig, DEFAULT_VAULT_DIRS } from "./vault_config.ts";

export const loadVaultRoot = (): URL => {
Expand Down
13 changes: 13 additions & 0 deletions src/unfold/pipeline/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ export const runValidate = async (
await deps.writeError(errorLines.join("\n"));
throw new Error("JSON-LD load failed");
}
if (nodes.length === 0) {
const lines = [
`JSON-LD validation skipped (mode: ${mode}):`,
` - no JSON-LD nodes found in ${vaultPath}`,
"Hint: export JSON-LD into the vault or set VAULT_BASE_URL.",
];
if (mode === "strict") {
await deps.writeError(lines.join("\n"));
throw new Error("JSON-LD validation failed");
}
await deps.writeWarning(lines.join("\n"));
return;
}

if (mode === "strict") {
const jsonSchemaErrors = await deps.validateNodesJsonSchema(nodes);
Expand Down
27 changes: 25 additions & 2 deletions src/unfold/pipeline/validate_test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { assert, assertRejects } from "@std/assert";
import { ValidationError } from "../inputs/jsonld/types.ts";
import { ValidationError, type VaultNode } from "../inputs/jsonld/types.ts";
import { ORPHAN_REACHABILITY_PREFIX } from "../inputs/jsonld/validator_reachability.ts";
import { runValidate, type ValidationDeps } from "./validate.ts";

const SAMPLE_NODES: VaultNode[] = [
{
"@type": "Catalog",
"@id": "https://example.org/catalog",
_source: { file: "vault/catalog.jsonld", path: "vault/catalog.jsonld" },
},
];

const createBaseDeps = (
overrides: Partial<ValidationDeps> = {},
): Partial<ValidationDeps> => ({
prepareVault: async () => {},
getVaultPath: () => "/vault",
loadVault: async () => ({ nodes: [], errors: [] }),
loadVault: async () => ({ nodes: SAMPLE_NODES, errors: [] }),
validateNodesJsonSchema: async () => [],
validateNodes: async () => [],
loadShapes: async () => ({ shapes: [] }),
Expand All @@ -22,6 +30,21 @@ const createBaseDeps = (
...overrides,
});

Deno.test("runValidate - dev mode warns when no JSON-LD nodes found", async () => {
const warnings: string[] = [];
const deps = createBaseDeps({
loadVault: async () => ({ nodes: [], errors: [] }),
writeWarning: async (text: string) => {
warnings.push(text);
},
});

await runValidate({ mode: "dev", deps });

assert(warnings.length === 1);
assert(warnings[0].includes("no JSON-LD nodes found"));
});

Deno.test("runValidate - dev mode warns on orphan reachability", async () => {
const warnings: string[] = [];
const deps = createBaseDeps({
Expand Down
38 changes: 23 additions & 15 deletions src/unfold/renderers/lume/jsonld_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,28 @@ type LoaderFunction = (path: string) => Promise<PageData>;
* Extract page data from a JSON-LD node
* Maps JSON-LD properties to Lume page data
*/
function extractPageData(node: JsonLdNode): PageData {
function extractPageData(node: JsonLdNode, sourcePath: string): PageData {
const extractedDate = extractDate(node);

const data: PageData = {
// Required Lume fields
url: extractUrl(node),
title: extractTitle(node),
url: extractUrl(node, sourcePath),
title: extractTitle(node, sourcePath),
date: extractedDate || new Date(), // Lume requires Date, not undefined

// Optional fields
description: extractString(node, "description"),

// Content
content: extractContent(node),
content: extractContent(node, sourcePath),

// JSON-LD metadata (full node for <script type="application/ld+json">)
// The layout template will inject this into the page head
jsonld: node,

// Metas for SEO
metas: {
title: extractTitle(node),
title: extractTitle(node, sourcePath),
description: extractString(node, "description"),
type: "article",
},
Expand All @@ -58,7 +58,7 @@ function extractPageData(node: JsonLdNode): PageData {
* Extract URL from @id
* Convert @id IRI to Lume-friendly URL path
*/
function extractUrl(node: JsonLdNode): string {
function extractUrl(node: JsonLdNode, sourcePath: string): string {
const id = node["@id"];
if (!id) {
throw new Error(`Node missing @id: ${JSON.stringify(node)}`);
Expand All @@ -67,17 +67,17 @@ function extractUrl(node: JsonLdNode): string {
try {
const url = new URL(id);
// Return pathname (e.g., /pages/hello from https://example.org/pages/hello)
return url.pathname + url.hash;
const path = url.pathname + url.hash;
return path.startsWith("/") ? path : urlFromPath(sourcePath);
} catch {
// If not a valid URL, use as-is
return id;
return urlFromPath(sourcePath);
}
}

/**
* Extract title from various JSON-LD properties
*/
function extractTitle(node: JsonLdNode): string {
function extractTitle(node: JsonLdNode, sourcePath: string): string {
// Try common title properties
const candidates = [
node.title,
Expand All @@ -95,7 +95,7 @@ function extractTitle(node: JsonLdNode): string {
}

// Fallback to @id
return extractUrl(node);
return extractUrl(node, sourcePath);
}

/**
Expand Down Expand Up @@ -142,7 +142,7 @@ function extractDate(node: JsonLdNode): Date | undefined {
/**
* Extract content from the node
*/
function extractContent(node: JsonLdNode): string {
function extractContent(node: JsonLdNode, sourcePath: string): string {
// Check for explicit content properties
const content = extractString(
node,
Expand All @@ -168,8 +168,8 @@ function extractContent(node: JsonLdNode): string {
if (keys.length === 0) {
return ""; // Just a reference, skip it
}
const sectionTitle = extractTitle(partNode);
const sectionContent = extractContent(partNode);
const sectionTitle = extractTitle(partNode, sourcePath);
const sectionContent = extractContent(partNode, sourcePath);
if (sectionContent) {
return `<section>\n<h2>${sectionTitle}</h2>\n${sectionContent}\n</section>`;
}
Expand All @@ -192,6 +192,14 @@ function extractContent(node: JsonLdNode): string {
return "";
}

const urlFromPath = (path: string): string => {
const normalized = path.replaceAll("\\", "/");
const withoutExt = normalized.endsWith(".jsonld")
? normalized.slice(0, -7)
: normalized;
return withoutExt.startsWith("/") ? withoutExt : `/${withoutExt}`;
};

/**
* Lume loader for .jsonld files
*/
Expand Down Expand Up @@ -224,7 +232,7 @@ export default function (): LoaderFunction {
return {} as PageData; // Return empty page data to skip this file
}

const data = extractPageData(mainNode);
const data = extractPageData(mainNode, path);

// Store all nodes for potential use
data.allNodes = nodes;
Expand Down
65 changes: 54 additions & 11 deletions src/unfold/site/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,38 @@ import { siteBuildConfig } from "./site_build_config.ts";
import { vaultConfig } from "../inputs/vault/vault_config.ts";
const DEFAULT_WATCH_DEBOUNCE_MS = 5000;

const normalizePages = (pages: Page[] | Iterable<Page>): Page[] =>
Array.isArray(pages) ? pages : Array.from(pages);

const normalizeJsonldPath = (url: string): string => {
const noFragment = url.split("#")[0];
const noQuery = noFragment.split("?")[0];
const trimmed = noQuery.replace(/^\/+/, "").replace(/\/$/, "");
if (!trimmed) {
return "index";
}
return trimmed.endsWith(".html") ? trimmed.slice(0, -5) : trimmed;
};

const writeJsonldPages = async (
site: ReturnType<typeof lume>,
pages: Page[],
): Promise<void> => {
for (const page of pages) {
const url = page.data?.url;
const rawJsonld = page.data?.jsonld;
if (typeof url !== "string" || !rawJsonld) {
continue;
}
const relPath = normalizeJsonldPath(url);
const outputPath = site.dest(join("jsonld", `${relPath}.jsonld`));
await Deno.mkdir(dirname(outputPath), { recursive: true });
const jsonld =
typeof rawJsonld === "string" ? JSON.parse(rawJsonld) : rawJsonld;
await Deno.writeTextFile(outputPath, JSON.stringify(jsonld, null, 2));
}
};

const getSiteUrl = (): string => siteBuildConfig.siteUrl;

const getSiteBasePath = () => {
Expand All @@ -36,15 +68,11 @@ const getVaultPath = (): string => {
const override = Deno.env.get("VAULT_PATH")?.trim();
if (!override) {
const workspaceRoot = getWorkspaceRoot().replace(/\/$/, "");
try {
const stat = Deno.statSync(join(workspaceRoot, "vault"));
if (stat.isDirectory) {
return "vault";
}
} catch {
// Fall through to repo root.
const resolved = vaultConfig.vaultPath;
if (isAbsolute(resolved) && resolved.startsWith(`${workspaceRoot}/`)) {
return relative(workspaceRoot, resolved);
}
return ".";
return resolved;
}
if (isAbsolute(override)) {
const workspaceRoot = getWorkspaceRoot().replace(/\/$/, "");
Expand All @@ -56,9 +84,6 @@ const getVaultPath = (): string => {
return override;
};

const getSiteDest = (): string =>
Deno.env.get("SITE_OUTPUT_DIR")?.trim() || ".unfold/site";

const getSiteDest = (): string =>
Deno.env.get("SITE_OUTPUT_DIR")?.trim() || ".unfold/site";

Expand All @@ -67,11 +92,27 @@ export const createSite = (): ReturnType<typeof lume> => {
const basePath = getSiteBasePath();
const workspaceRoot = getWorkspaceRoot();
const vaultPath = getVaultPath();
const layoutPath = getLayoutPath();
const normalizedWorkspaceRoot = workspaceRoot.replace(/\/$/, "");
const srcPath = vaultPath.startsWith(`${normalizedWorkspaceRoot}/`)
? relative(normalizedWorkspaceRoot, vaultPath)
: vaultPath;
const destPath = getSiteDest();
const srcRoot = isAbsolute(srcPath) ? srcPath : join(workspaceRoot, srcPath);
const includesDir = join(srcRoot, "_includes");
const layoutTarget = join(includesDir, "layout.tmpl.ts");
try {
Deno.statSync(layoutTarget);
} catch {
Deno.mkdirSync(includesDir, { recursive: true });
try {
Deno.copyFileSync(layoutPath, layoutTarget);
} catch (error) {
if (!(error instanceof Deno.errors.AlreadyExists)) {
throw error;
}
}
}

const site = lume({
cwd: workspaceRoot,
Expand All @@ -82,6 +123,8 @@ export const createSite = (): ReturnType<typeof lume> => {
debounce: DEFAULT_WATCH_DEBOUNCE_MS,
},
});
site.loadPages([".jsonld"], jsonLdLoader());
site.data("layout", "layout.tmpl.ts");

const ignorePrefixes = [
".cursor/",
Expand Down
Loading