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
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit"
},
"sonarlint.connectedMode.project": {
"connectionId": "cube-27",
"projectKey": "Cube-27_Citeladder"
}
}
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ FROM node@sha256:662933cf47f013bc8e4beb31a6116448427a82057ba7c42c97e4c5ba766504c
ENV PNPM_HOME=/pnpm
ENV PATH="${PNPM_HOME}:${PATH}"
WORKDIR /app
RUN npm install --global --ignore-scripts pnpm@12.8.1
RUN npm install --global --ignore-scripts pnpm@12.9.1
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
COPY frontend/patches ./patches
COPY frontend/packages/contracts/package.json ./packages/contracts/
COPY frontend/services/api/package.json ./services/api/
COPY frontend/packages/mcp-app/package.json ./packages/mcp-app/
Expand Down
6 changes: 6 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ pnpm dev:vite # Vite authenticated SPA: http://127.0.0.1:3001/logi
Vite proxies these paths to the API service. Marketing Worker requests use
its protected upstream configuration.

The workspace pins a pnpm patch for Astro 7.3.5's Windows directory-cleanup
fallback, which uses `fs.rmSync` for Node 26 compatibility. Frozen installs
apply `frontend/patches/astro@7.3.5.patch`; Docker dependency stages copy the
patches alongside the workspace manifest. Remove the patch when upgrading to
an Astro release containing [the upstream fix](https://github.com/withastro/astro/pull/18193).

Fonts come only from the private `Cube-27/cube27-fonts` repo (some faces there
are licensed for self-hosting, not redistribution). `pnpm fonts:pull` uses your
`gh` login to copy the ones in use into the gitignored `public/fonts/`, and
Expand Down
13 changes: 13 additions & 0 deletions docs/frontend-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ The documentation Worker serves assets only, with no Worker script, so Astro emi

## Routes and shared shell

The marketing `/tools` hub links to six server-rendered `/tools/:slug` pages:
crawler rules testing, robots.txt generation, meta/directive inspection,
structured data generation, sitemap comparison and social previews. These are
browser-only utilities over bounded pasted inputs and local files; they do not
fetch sites, call providers, persist results or put submissions in URLs.
The existing crawler catalog supplies public bot metadata and rules use the same
`robots-parser` dependency as acquisition. Tool metadata lives in
`lib/marketing-content/tools.ts`, browser admission in `lib/config/free-tools.ts`,
and local transformations in `lib/free-tools/`. These reports are not persisted
Site Health evidence or claims of live access, indexability or citation eligibility.
Social previews read local raster images; published image URLs only enter exported
markup. Sitemap indexes compare declared child sitemap addresses without recursion.

| Surface | Browser location | Feature owner |
|---|---|---|
| Overview and company facts | /projects | [Onboarding](onboarding.md) |
Expand Down
7 changes: 5 additions & 2 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ WORKDIR /app

# Node 26 no longer bundles corepack, so pnpm is installed from npm.
# Keep this version in lockstep with the packageManager field in frontend/package.json.
RUN npm install --global --ignore-scripts pnpm@12.8.1
RUN npm install --global --ignore-scripts pnpm@12.9.1
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
COPY frontend/patches ./patches
COPY frontend/packages/contracts/package.json ./packages/contracts/
COPY frontend/services/api/package.json ./services/api/
COPY frontend/packages/mcp-app/package.json ./packages/mcp-app/
Expand All @@ -31,9 +32,10 @@ ENV PUBLIC_DOCS_ORIGIN=${PUBLIC_DOCS_ORIGIN}
ENV LOCAL_COMPOSE_BUILD=true
WORKDIR /app

RUN npm install --global --ignore-scripts pnpm@12.8.1
RUN npm install --global --ignore-scripts pnpm@12.9.1
COPY --from=dependencies /app/node_modules ./node_modules
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
COPY frontend/patches ./patches
COPY frontend/apps/marketing ./apps/marketing
COPY frontend/apps/app/src/globals.css frontend/apps/app/src/website-type.css frontend/apps/app/src/ui-motion.css ./apps/app/src/
COPY frontend/components ./components
Expand All @@ -42,6 +44,7 @@ COPY frontend/packages ./packages
COPY frontend/public ./public
COPY frontend/scripts ./scripts
COPY frontend/postcss.config.mjs frontend/tsconfig.json ./
COPY frontend/services/api/src/config/crawlers.json ./services/api/src/config/crawlers.json
RUN pnpm build:marketing

FROM node@sha256:662933cf47f013bc8e4beb31a6116448427a82057ba7c42c97e4c5ba766504c2 AS runtime
Expand Down
5 changes: 3 additions & 2 deletions frontend/apps/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ ENV PATH="${PNPM_HOME}:${PATH}"
WORKDIR /app

# Keep this version in lockstep with packageManager in frontend/package.json.
RUN npm install --global --ignore-scripts pnpm@12.8.1
RUN npm install --global --ignore-scripts pnpm@12.9.1
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
COPY frontend/patches ./patches
COPY frontend/packages/contracts/package.json ./packages/contracts/
COPY frontend/services/api/package.json ./services/api/
COPY frontend/packages/mcp-app/package.json ./packages/mcp-app/
Expand All @@ -34,7 +35,7 @@ ENV PUBLIC_DOCS_ORIGIN=${PUBLIC_DOCS_ORIGIN}
ENV LOCAL_COMPOSE_BUILD=true
WORKDIR /app

RUN npm install --global --ignore-scripts pnpm@12.8.1
RUN npm install --global --ignore-scripts pnpm@12.9.1
COPY --from=dependencies /app/node_modules ./node_modules
COPY frontend ./
RUN pnpm build:vite
Expand Down
5 changes: 3 additions & 2 deletions frontend/apps/docs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ ENV PATH="${PNPM_HOME}:${PATH}"
WORKDIR /app

# Keep this version in lockstep with packageManager in frontend/package.json.
RUN npm install --global --ignore-scripts pnpm@12.8.1
RUN npm install --global --ignore-scripts pnpm@12.9.1
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
COPY frontend/patches ./patches
COPY frontend/packages/contracts/package.json ./packages/contracts/
COPY frontend/services/api/package.json ./services/api/
COPY frontend/packages/mcp-app/package.json ./packages/mcp-app/
Expand All @@ -21,7 +22,7 @@ ENV PATH="${PNPM_HOME}:${PATH}"
ENV PUBLIC_DOCS_ORIGIN=${PUBLIC_DOCS_ORIGIN}
WORKDIR /app

RUN npm install --global --ignore-scripts pnpm@12.8.1
RUN npm install --global --ignore-scripts pnpm@12.9.1
COPY --from=dependencies /app/node_modules ./node_modules
COPY frontend ./
RUN pnpm build:docs
Expand Down
7 changes: 7 additions & 0 deletions frontend/apps/marketing/src/pages/sitemap.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { POSTS } from '@/lib/marketing-content/blog';
import { COMPETITORS } from '@/lib/marketing-content/compare';
import { FOOTER_LEGAL_LINKS } from '@/lib/marketing-content/legal';
import { absoluteUrl } from '@/lib/seo/site';
import { FREE_TOOLS } from '@/lib/marketing-content/tools';

type RouteEntry = {
path: string;
Expand All @@ -22,6 +23,12 @@ const staticRoutes: readonly RouteEntry[] = [
{ path: '/entity-map', changeFrequency: 'monthly', priority: 0.5 },
{ path: '/blog', changeFrequency: 'weekly', priority: 0.6 },
{ path: '/compare', changeFrequency: 'monthly', priority: 0.6 },
{ path: '/tools', changeFrequency: 'monthly', priority: 0.8 },
...FREE_TOOLS.map((tool) => ({
path: `/tools/${tool.slug}`,
changeFrequency: 'monthly' as const,
priority: 0.7,
})),
];

const escapeXml = (value: string) =>
Expand Down
40 changes: 40 additions & 0 deletions frontend/apps/marketing/src/pages/tools/[slug].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
import MarketingLayout from '../../layouts/MarketingLayout.astro';
import { PageHero } from '@/components/marketing/primitives/page-hero';
import { Section } from '@/components/marketing/primitives/section';
import { FREE_TOOLS } from '@/lib/marketing-content/tools';
import { CrawlerChecker, RobotsGenerator } from '@/components/marketing/tools/robots-tools';
import { MetaChecker, StructuredDataBuilder } from '@/components/marketing/tools/markup-tools';
import { SitemapComparison } from '@/components/marketing/tools/sitemap-tool';
import { SocialPreview } from '@/components/marketing/tools/social-tool';
import catalog from '@/services/api/src/config/crawlers.json';

const tool = FREE_TOOLS.find((entry) => entry.slug === Astro.params.slug);
if (!tool) return new Response('Tool not found', { status: 404 });
// Project only the public catalog fields; no server configuration enters the browser.
const bots = catalog.bots.map((bot) => ({ label: bot.label, purpose: bot.purpose, tokens: bot.robots_tokens }));
---
<MarketingLayout title={`Free ${tool.title} | CiteLadder`} description={tool.description} canonical={`/tools/${tool.slug}`}>
<main id="main">
<PageHero eyebrow="Free tools · No signup" title={tool.title} lead={tool.description} />
<Section rhythm="tight">
<a href="/tools" class="website-body text-accent-text underline underline-offset-2">← All free tools</a>
<p class="website-body text-muted">Your input is processed in this browser. Text and files are not uploaded or saved by this tool. Use up to 200,000 characters per text area.</p>
<noscript><p>Enable JavaScript to run this browser-based tool. No account is required.</p></noscript>
{tool.slug === 'ai-crawler-checker' && <CrawlerChecker bots={bots} client:load />}
{tool.slug === 'robots-txt-generator' && <RobotsGenerator bots={bots} client:load />}
{tool.slug === 'meta-directive-checker' && <MetaChecker client:load />}
{tool.slug === 'structured-data-builder' && <StructuredDataBuilder client:load />}
{tool.slug === 'sitemap-comparison' && <SitemapComparison client:load />}
{tool.slug === 'social-preview' && <SocialPreview client:load />}
</Section>
<Section rhythm="tight" tone="sunken">
<h2 class="website-feature-heading">What this tool can tell you</h2>
<p class="website-body text-muted max-w-3xl">{tool.limitation}</p>
<p class="website-body">Need evidence across your website? <a href="/solutions" class="text-accent-text underline underline-offset-2">Explore CiteLadder’s site-wide workflow</a>.</p>
<nav aria-label="Related tools" class="website-body flex flex-wrap gap-x-6 gap-y-3">
{FREE_TOOLS.filter((item) => item.slug !== tool.slug).map((item) => <a class="text-accent-text underline underline-offset-2" href={`/tools/${item.slug}`}>{item.title}</a>)}
</nav>
</Section>
</main>
</MarketingLayout>
7 changes: 7 additions & 0 deletions frontend/apps/marketing/src/pages/tools/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
import MarketingLayout from '../../layouts/MarketingLayout.astro';
import { ToolsIndex } from '@/components/marketing/tools/tools-index';
---
<MarketingLayout title="Free Website and AI Crawler Tools | CiteLadder" description="Six free browser-based tools for crawler rules, robots.txt, indexing directives, structured data, sitemap comparison and social previews. No signup required." canonical="/tools">
<main id="main"><ToolsIndex /></main>
</MarketingLayout>
1 change: 1 addition & 0 deletions frontend/components/marketing/chrome/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const FOOTER_COLUMNS: readonly FooterColumn[] = [
links: [
{ label: 'Docs', href: docsHref(), external: true },
{ label: 'Blog', href: '/blog' },
{ label: 'Free tools', href: '/tools' },
{ label: 'Changelog', href: docsHref('/changelog/'), external: true },
{ label: 'FAQ', href: '/faq' },
],
Expand Down
108 changes: 108 additions & 0 deletions frontend/components/marketing/tools/markup-tools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { Field } from '@/components/ui/field';
import { Select } from '@/components/ui/select';
import { inspectMarkup } from '@/lib/free-tools/markup';
import { buildSchema, type SchemaKind } from '@/lib/free-tools/generators';
import { ToolForm, ToolInput } from './tool-form';

export function MetaChecker() {
const [html, setHtml] = useState('');
const [headers, setHeaders] = useState('');
return (
<ToolForm
action="Inspect declarations"
run={() => inspectMarkup(html, headers)}
signature={JSON.stringify([html, headers])}
sample={() => {
setHtml(
'<title>Example guide</title>\n<meta name="robots" content="noindex, follow">\n<link rel="canonical" href="https://example.com/guide">',
);
setHeaders('HTTP/2 200\nX-Robots-Tag: googlebot: noindex');
}}
>
<ToolInput
label="Page HTML"
hint="Paste source HTML. It is inspected locally and never rendered as a webpage."
value={html}
onChange={setHtml}
multiline
/>
<ToolInput
label="Response headers (optional)"
hint="Include X-Robots-Tag and Link headers if available. Do not include cookies or authorization headers."
value={headers}
onChange={setHeaders}
multiline
/>
</ToolForm>
);
}

export function StructuredDataBuilder() {
const [kind, setKind] = useState<SchemaKind>('Article');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [detail, setDetail] = useState('');
const [date, setDate] = useState('');
const breadcrumb = kind === 'BreadcrumbList';
let detailLabel = 'Logo URL (optional)';
if (breadcrumb) detailLabel = 'Breadcrumbs';
else if (kind === 'Article') detailLabel = 'Author name (person)';
return (
<ToolForm
action="Build JSON-LD"
filename="structured-data.html"
run={() => buildSchema(kind, name, url, detail, date)}
signature={JSON.stringify([kind, name, url, detail, date])}
sample={() => {
setKind('Article');
setName('A practical guide to crawl access');
setUrl('https://example.com/guide');
setDetail('Alex Morgan');
setDate('2026-10-06');
}}
>
<Field label="Schema type">
{(props) => (
<Select
{...props}
ariaLabel="Schema type"
value={kind}
options={['Article', 'Organization', 'BreadcrumbList'].map((value) => ({
value: value as SchemaKind,
label: value,
}))}
onValueChange={(next) => {
setKind(next);
setDetail('');
}}
/>
)}
</Field>
{!breadcrumb && (
<>
<ToolInput
label={kind === 'Article' ? 'Headline' : 'Organization name'}
value={name}
onChange={setName}
/>
<ToolInput label="Page URL" value={url} onChange={setUrl} />
</>
)}
<ToolInput
label={detailLabel}
hint={
breadcrumb
? 'One Name | https://example.com/path per line, in navigation order.'
: undefined
}
value={detail}
onChange={setDetail}
multiline={breadcrumb}
/>
{kind === 'Article' && (
<ToolInput label="Publication date" type="date" value={date} onChange={setDate} />
)}
</ToolForm>
);
}
Loading
Loading