feat(marketing): phase 4 — the marketing pages
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
PLAN.md §13 phase 4: /features/, /architecture/, /modules/, /integrations/, and /community/ — plus the two scope items the phase table never assigned to anyone. Six decisions taken by the org lead before coding, recorded in PLAN.md §10 as D20-D25: - D20 /features/ is the homepage's list with a `detail` line, not a second list. One data file, two renderings, so they cannot disagree about what exists. - D21 /architecture/ draws reasons, not reference: three new inline SVGs, one per boundary. No endpoint tables, no config keys — those are phase 8's and stay canonical in docs/. - D22 The deliberate absences of §2 become one tagged data file, rendered on the three pages that promise them. - D23 Phase 4 absorbs /community/ (specified in §10 and §14 N3, linked from the header since phase 1, built by no phase) and checkLinks.mjs. - D24 `needsModule`: writing the Teams detail exposed a false claim phase 3 shipped. Teams are module-sourced only — teams.module_id is NOT NULL, there is no create route, sync is gated on providerModuleId() — so the Community group no longer says a bare core does all of it. - D25 The per-capability demo affordance brand.json had promised since phase 2 is a deep link, filled at boot from data-demo-path. checkLinks.mjs reads the built HTML rather than src/, because half these links are assembled from data files and template literals. Its PLANNED_ROUTES list is checked in both directions, so it cannot rot into a permanent exemption. applyBrand.mjs gained a pass that recomputes deep links from their immutable path, making it idempotent and reversible; checkBrand.mjs lifts that pattern out and runs it against the stock markup so the two cannot drift. Both proved against a real mount, in both directions. Fixes a cascade bug the checks could not see: [data-demo-url=''] and a scoped component class are both specificity 0,1,0, so .demo-link's `display` beat the hide rule and twelve links to a nonexistent demo rendered, each resolving to the current page. The rule is now !important. The four diagrams' shared SVG vocabulary moved to src/styles/diagram.css. Verified from a clean checkout: npm ci, all five checks, astro check (0 errors), production build, and a live browser pass at desktop and 390px. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,34 @@ if (demoFrom !== demoTo) {
|
||||
replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo's DEEP links (§15 / D25), which `/features/` writes one of per capability that
|
||||
* has a stable public route:
|
||||
*
|
||||
* <a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market">see it live</a>
|
||||
*
|
||||
* The slot above cannot express these. It is a literal string swap of a whole URL, so it
|
||||
* can only ever put the demo's root in an `href` — and reversing it would not even find a
|
||||
* deep link, whose `href` is the root plus a path and therefore matches no literal the
|
||||
* script knows.
|
||||
*
|
||||
* This pass is a different shape on purpose: it does not replace a previous value, it
|
||||
* RECOMPUTES both attributes from `data-demo-path`, which never changes. That makes it
|
||||
* idempotent and exactly reversible, so it runs unconditionally in the loop below rather
|
||||
* than only when the demo URL moved. `data-demo-url` is still filled with the bare root
|
||||
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
|
||||
* both kinds of link, and only the `href` differs.
|
||||
*/
|
||||
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g;
|
||||
|
||||
const deepLinkTo = (demoPath) => {
|
||||
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
|
||||
return (
|
||||
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
|
||||
`data-demo-path="${demoPath}"`
|
||||
);
|
||||
};
|
||||
|
||||
if (!replacements.length) {
|
||||
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
||||
process.exit(0);
|
||||
@@ -198,6 +226,7 @@ function* walk(dir) {
|
||||
}
|
||||
|
||||
const counts = new Map(replacements.map((r) => [r.field, 0]));
|
||||
counts.set('demoDeep', 0);
|
||||
let filesTouched = 0;
|
||||
|
||||
for (const file of walk(CLIENT)) {
|
||||
@@ -210,6 +239,15 @@ for (const file of walk(CLIENT)) {
|
||||
after = after.split(from).join(to);
|
||||
}
|
||||
|
||||
// After the literal swaps, never before: the plain-slot replacement also matches the
|
||||
// first two attributes of a deep link, so it runs first and this pass corrects the
|
||||
// `href` it just wrote. Recomputing rather than replacing is what makes that safe.
|
||||
after = after.replace(DEEP_LINK, (whole, demoPath) => {
|
||||
const rebuilt = deepLinkTo(demoPath);
|
||||
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
|
||||
return rebuilt;
|
||||
});
|
||||
|
||||
if (after !== before) {
|
||||
writeFileSync(file, after);
|
||||
filesTouched++;
|
||||
@@ -226,6 +264,11 @@ for (const { field, from, to } of replacements) {
|
||||
if (demoFrom !== demoTo) {
|
||||
console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
|
||||
}
|
||||
if (counts.get('demoDeep')) {
|
||||
console.log(
|
||||
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
|
||||
);
|
||||
}
|
||||
|
||||
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
|
||||
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||
|
||||
@@ -290,6 +290,87 @@ if (!attrTemplate) {
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
5. The demo DEEP-link contract (§15 / D25)
|
||||
=======================================================================================
|
||||
|
||||
`/features/` links individual capabilities into the demo, which the slot in §4 cannot
|
||||
express — it swaps a whole URL, so it can only ever produce the demo's root. Those links
|
||||
carry a third attribute and `applyBrand.mjs` recomputes all three from it.
|
||||
|
||||
Same failure mode as §4 and the same reason to check it: a template and a script with no
|
||||
shared code, agreeing on an exact byte sequence, where disagreement is silent. This one
|
||||
is worse in one respect — a broken deep link is INVISIBLE in a stock build, because the
|
||||
stock build hides every demo link. It would first appear on the day the org lead sets
|
||||
`demoUrl` and finds the new links pointing at the demo's front page, or at nothing.
|
||||
|
||||
The regex is not retyped here either: it is lifted out of `applyBrand.mjs` and run
|
||||
against the stock literal, so this fails if the script's pattern stops matching what the
|
||||
templates write — whichever side moved. */
|
||||
|
||||
const deepPattern = applyForCheck.match(/const DEEP_LINK = \/(.*)\/g;/);
|
||||
const EMPTY_DEEP_PREFIX = 'href="" data-demo-url="" ';
|
||||
let deepLinkCount = 0;
|
||||
|
||||
if (!deepPattern) {
|
||||
fail(
|
||||
'applyBrand.mjs no longer defines DEEP_LINK as a single /…/g literal.\n' +
|
||||
' §15/D25 relies on it to fill the per-capability demo links. Update this check to\n' +
|
||||
' match the new shape rather than deleting it.'
|
||||
);
|
||||
} else {
|
||||
// Does the script's own pattern still match what a template writes in a stock build?
|
||||
const sample = `${EMPTY_DEEP_PREFIX}data-demo-path="/example"`;
|
||||
let matches = false;
|
||||
try {
|
||||
matches = new RegExp(deepPattern[1]).test(sample);
|
||||
} catch (error) {
|
||||
fail(`applyBrand.mjs's DEEP_LINK is not a usable pattern: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!matches) {
|
||||
fail(
|
||||
`applyBrand.mjs's DEEP_LINK no longer matches the stock markup \`${sample}\`.\n` +
|
||||
' Every per-capability demo link would be left empty and hidden, on a deployment\n' +
|
||||
' that has a demo configured — which is the one place nobody would look.'
|
||||
);
|
||||
}
|
||||
|
||||
const deepStrays = [];
|
||||
let deepLinks = 0;
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
if (path.extname(file) !== '.astro') continue;
|
||||
|
||||
// Blanked, not stripped — same reason as §4: the line numbers reported have to be the
|
||||
// ones in the file.
|
||||
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
||||
const source = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
for (const match of source.matchAll(/data-demo-path/g)) {
|
||||
deepLinks++;
|
||||
const start = match.index - EMPTY_DEEP_PREFIX.length;
|
||||
if (start < 0 || source.slice(start, match.index) !== EMPTY_DEEP_PREFIX) {
|
||||
deepStrays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const site of deepStrays) {
|
||||
fail(
|
||||
`${site} writes data-demo-path without the exact prefix \`${EMPTY_DEEP_PREFIX}\`.\n` +
|
||||
' applyBrand.mjs matches all three attributes together and in that order; anything\n' +
|
||||
' else is invisible to it and the link will never point anywhere.'
|
||||
);
|
||||
}
|
||||
|
||||
deepLinkCount = deepLinks;
|
||||
}
|
||||
|
||||
/* ======================================================================================= */
|
||||
|
||||
if (failures.length) {
|
||||
@@ -301,5 +382,6 @@ if (failures.length) {
|
||||
|
||||
console.log(
|
||||
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
|
||||
`every rewritable string is safe to replace, and the demo slot matches its contract.`
|
||||
`every rewritable string is safe to replace, and the demo slot plus ${deepLinkCount} ` +
|
||||
`deep link(s) match their contracts.`
|
||||
);
|
||||
|
||||
311
scripts/checkLinks.mjs
Normal file
311
scripts/checkLinks.mjs
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkLinks.mjs — PLAN.md §12
|
||||
*
|
||||
* Two rules, both of which §12 states and neither of which had a check until phase 4:
|
||||
*
|
||||
* 1. Every internal link resolves.
|
||||
* 2. Every outbound link into a RunicGateway repository points at a BRANCH path, never a
|
||||
* commit permalink.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS THE BUILD AND NOT THE SOURCE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The obvious implementation greps `href="…"` out of `src/**` and resolves it against the
|
||||
* file tree. It would have missed most of what phase 4 added. Half the links on these pages
|
||||
* are built from data — `capabilityGroups`, `notBuilt.mjs`, a template literal over
|
||||
* `platform.gitea.base` — and a source scan sees an expression rather than a URL. A link
|
||||
* that is wrong in a data file is exactly as broken as one that is wrong in markup, and it
|
||||
* is harder to spot by eye, so it is the one that most needs checking.
|
||||
*
|
||||
* So this runs against `dist/client` after a build, where every link is a real string. The
|
||||
* cost is that the check needs a build first, which is why it sits after `npm run build` in
|
||||
* `verify` and in CI. A stale `dist` would check stale links, and that is the one failure
|
||||
* mode worth knowing about — running it by hand after editing a page means building first.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IT DELIBERATELY DOES NOT CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `/brand/*` — those URLs are served by a route that derives them on request from whatever
|
||||
* is mounted, so nothing corresponding exists in `dist/client` to point at. They are not
|
||||
* unchecked: `scripts/checkBrand.mjs` already resolves every one of them against that
|
||||
* route's own allowlist, which is a stronger check than file existence.
|
||||
*
|
||||
* Off-site URLs are not fetched. A build that fails because gnu.org is slow is a build
|
||||
* that teaches people to ignore this check. The one outbound rule here is about the SHAPE
|
||||
* of a URL, which is decidable without the network.
|
||||
*
|
||||
* In-page fragments (`#main`) are not resolved against the ids on the page. It would be a
|
||||
* fair check to add; it is not one §12 asks for, and the site has exactly one of them.
|
||||
*
|
||||
* node scripts/checkLinks.mjs [--dist <path>]
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const distArg = process.argv.indexOf('--dist');
|
||||
const DIST =
|
||||
distArg !== -1 && process.argv[distArg + 1]
|
||||
? path.resolve(process.argv[distArg + 1])
|
||||
: path.join(ROOT, 'dist', 'client');
|
||||
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
/** `gitea.whitlocktech.com`, from the same place every page reads it. */
|
||||
const GITEA_HOST = new URL(platform.gitea.base).host;
|
||||
|
||||
/**
|
||||
* Prefixes served by a route rather than by a file in the build. A link starting with one
|
||||
* of these is somebody else's check — see the header.
|
||||
*/
|
||||
const RUNTIME_PREFIXES = ['/brand/'];
|
||||
|
||||
/**
|
||||
* Routes the site links today that a later phase builds.
|
||||
*
|
||||
* This exists because of a convention phase 3 recorded and phase 1 started: the header,
|
||||
* the footer and the homepage link the FINAL routes of §10 rather than growing links phase
|
||||
* by phase. Nothing is deployed until phase 12, so no visitor ever meets one of these
|
||||
* 404s, and no page has to be revisited later to add a link that was always going to be
|
||||
* there. That convention and rule 1 of this check are in direct tension, and this is where
|
||||
* the tension is resolved — explicitly, with a phase against each entry, rather than by
|
||||
* weakening the rule.
|
||||
*
|
||||
* It is self-cleaning in both directions, which is the only reason it is safe to have:
|
||||
*
|
||||
* - a link to a route that is neither built nor listed here FAILS, so the list cannot be
|
||||
* used by accident;
|
||||
* - an entry here whose route HAS since been built also fails, so the list cannot rot
|
||||
* into a permanent exemption after the page arrives.
|
||||
*
|
||||
* Adding to it is a deliberate act. If a route is not in §10, it does not belong here.
|
||||
*/
|
||||
const PLANNED_ROUTES = new Map([
|
||||
['/app/', 'phase 5 — the Android app page'],
|
||||
['/beta/', 'phase 5 — the closed-beta signup'],
|
||||
['/privacy/', 'phase 6 — the privacy policy'],
|
||||
['/terms/', 'phase 6 — the terms'],
|
||||
]);
|
||||
|
||||
/** Planned routes actually linked from somewhere, so the reverse check can be reported. */
|
||||
const plannedSeen = new Set();
|
||||
|
||||
const failures = [];
|
||||
let linksChecked = 0;
|
||||
let outboundChecked = 0;
|
||||
|
||||
function fail(file, line, message) {
|
||||
failures.push({ file, line, message });
|
||||
}
|
||||
|
||||
async function* walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else if (path.extname(entry.name) === '.html') yield full;
|
||||
}
|
||||
}
|
||||
|
||||
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
||||
|
||||
/**
|
||||
* Does a site-absolute path correspond to something the build will serve?
|
||||
*
|
||||
* Astro is configured with `format: 'directory'`, so `/features/` is
|
||||
* `dist/client/features/index.html`. The other shapes are accepted because a route can
|
||||
* legitimately be a file — `/manifest.webmanifest` is one, and `/404.html` is another.
|
||||
*/
|
||||
function resolvesInBuild(pathname) {
|
||||
const clean = pathname.replace(/[?#].*$/, '');
|
||||
const relative = decodeURIComponent(clean).replace(/^\/+/, '');
|
||||
const base = path.join(DIST, relative);
|
||||
|
||||
const candidates = [
|
||||
path.join(base, 'index.html'),
|
||||
`${base.replace(/[\\/]+$/, '')}.html`,
|
||||
base.replace(/[\\/]+$/, ''),
|
||||
];
|
||||
|
||||
return candidates.some((candidate) => {
|
||||
if (!existsSync(candidate)) return false;
|
||||
// A bare directory that has no index.html is not a page anybody can open.
|
||||
return statSync(candidate).isFile();
|
||||
});
|
||||
}
|
||||
|
||||
if (!existsSync(DIST)) {
|
||||
console.error(
|
||||
`\ncheckLinks: no build at ${path.relative(ROOT, DIST)}.\n\n` +
|
||||
' This check reads the built HTML rather than the source, so that links written by\n' +
|
||||
' data files and template literals are checked as the strings they become. Run\n' +
|
||||
' `npm run build` first — `npm run verify` already does.\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
1. Internal links resolve
|
||||
======================================================================================= */
|
||||
|
||||
for await (const file of walk(DIST)) {
|
||||
const relative = path.relative(ROOT, file);
|
||||
const source = readFileSync(file, 'utf8');
|
||||
|
||||
for (const match of source.matchAll(/(?:href|src)="([^"]*)"/g)) {
|
||||
const value = match[1];
|
||||
|
||||
// Off-site, protocol-relative, and the non-navigational schemes. `mailto:` addresses
|
||||
// are checkFacts.mjs's business (D13) and are not links to anywhere on this site.
|
||||
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value)) continue;
|
||||
|
||||
// Fragments and query-only links stay on the page they are already on.
|
||||
if (!value || value.startsWith('#') || value.startsWith('?')) continue;
|
||||
|
||||
// Relative links. Astro emits site-absolute paths for everything the site itself
|
||||
// writes; a relative one is almost certainly a mistake, but resolving it correctly
|
||||
// needs the emitting page's directory, so it is reported rather than guessed at.
|
||||
if (!value.startsWith('/')) {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`relative link "${value}" — write it site-absolute, starting with "/", so it means ` +
|
||||
`the same thing from every page that renders the component`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RUNTIME_PREFIXES.some((prefix) => value.startsWith(prefix))) continue;
|
||||
|
||||
// The demo slot and its deep links ship empty and hidden in a stock build (§15/D25);
|
||||
// `href=""` is the contract, not a broken link. checkBrand.mjs owns their shape.
|
||||
if (value === '') continue;
|
||||
|
||||
linksChecked++;
|
||||
|
||||
if (resolvesInBuild(value)) continue;
|
||||
|
||||
const planned = PLANNED_ROUTES.get(value.replace(/[?#].*$/, ''));
|
||||
if (planned) {
|
||||
plannedSeen.add(value.replace(/[?#].*$/, ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`"${value}" does not resolve — nothing in the build serves it.\n` +
|
||||
` If a later phase builds it, add it to PLANNED_ROUTES in this script with the\n` +
|
||||
` phase that does. If not, the link is wrong.`
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================================================
|
||||
2. Outbound repository links point at a branch, not a commit
|
||||
=====================================================================================
|
||||
|
||||
§12's rule, and the reason for it: a commit permalink is a fact frozen at a sha while
|
||||
the document it names keeps moving. Every link on this site into one of these
|
||||
repositories is meant to show a reader the CURRENT state of something — the module
|
||||
contract, the operator guide, the protocol — and a permalink quietly stops doing that
|
||||
the day after it is written, without ever 404ing. It is the failure mode a link
|
||||
checker would otherwise call healthy.
|
||||
|
||||
Gitea writes both shapes as `/<owner>/<repo>/src/<kind>/<ref>/…`, so the kind segment
|
||||
is what decides it, and a 40-character hex ref is caught even when the kind segment
|
||||
says branch — which is what a "branch" named after a sha actually is. */
|
||||
|
||||
for (const match of source.matchAll(/https?:\/\/[^\s"'<>)]+/g)) {
|
||||
const raw = match[1] ?? match[0];
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (url.host !== GITEA_HOST) continue;
|
||||
|
||||
outboundChecked++;
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
// <owner>/<repo>/<kind>/<refkind>/<ref>/…
|
||||
const kind = segments[2];
|
||||
const refKind = segments[3];
|
||||
const ref = segments[4];
|
||||
|
||||
if (!['src', 'raw', 'media'].includes(kind)) continue;
|
||||
|
||||
if (refKind === 'commit' || refKind === 'tag') {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`${raw}\n points at a ${refKind}, not a branch. §12 requires branch paths, so a ` +
|
||||
`reader always\n sees the document as it is now rather than as it was.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`${raw}\n names a commit sha as its ref. Use a branch name — "main" for anything ` +
|
||||
`canonical.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
3. The planned-route list has not rotted
|
||||
=======================================================================================
|
||||
|
||||
The half that makes an exemption list safe. Once a phase builds one of these, the entry
|
||||
stops being a promise and starts being a hole in rule 1 — so the build fails until it is
|
||||
deleted. Reported per route, with the phase that was waiting for it, because the person
|
||||
who just built the page is the person who should remove the line. */
|
||||
|
||||
const selfSource = readFileSync(path.join(ROOT, 'scripts/checkLinks.mjs'), 'utf8');
|
||||
|
||||
for (const [route, owner] of PLANNED_ROUTES) {
|
||||
if (!resolvesInBuild(route)) continue;
|
||||
const entry = selfSource.indexOf(`['${route}'`);
|
||||
fail(
|
||||
'scripts/checkLinks.mjs',
|
||||
entry === -1 ? 1 : lineOf(selfSource, entry),
|
||||
`PLANNED_ROUTES still lists "${route}" (${owner}), but the build now serves it.\n` +
|
||||
` Delete the entry: every link to it is checked properly from here on.`
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('\ncheckLinks: broken or non-canonical links.\n');
|
||||
for (const failure of failures) {
|
||||
console.error(` ${failure.file}:${failure.line}\n ${failure.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pending = [...plannedSeen].sort();
|
||||
|
||||
console.log(
|
||||
`checkLinks: ${linksChecked} internal link(s) resolve and ${outboundChecked} repository ` +
|
||||
`link(s) point at a branch.`
|
||||
);
|
||||
|
||||
if (pending.length) {
|
||||
console.log(
|
||||
` ${pending.length} link(s) point at a planned route: ` +
|
||||
`${pending.join(', ')} — allowed until the phase that builds it.`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user