@coralreefventures/site 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ /* The marketing pages' type scale, applied to Mantine's Title and Text (and to plain elements in server pages). */
2
+
3
+ .display {
4
+ margin: 0;
5
+ font-size: 52px;
6
+ line-height: 1.15;
7
+ letter-spacing: -0.01em;
8
+ text-wrap: balance;
9
+
10
+ @media (max-width: 48em) {
11
+ font-size: 34px;
12
+ }
13
+ }
14
+
15
+ .title {
16
+ composes: display;
17
+ font-size: 48px;
18
+ }
19
+
20
+ .lead {
21
+ margin: 20px 0 0;
22
+ max-width: 50rem;
23
+ font-size: 20px;
24
+ line-height: 1.5;
25
+ color: var(--site-muted);
26
+
27
+ @media (max-width: 48em) {
28
+ font-size: 18px;
29
+ }
30
+ }
31
+
32
+ .h2 {
33
+ margin: 0;
34
+ font-size: 30px;
35
+ line-height: 1.25;
36
+ text-wrap: balance;
37
+
38
+ @media (max-width: 48em) {
39
+ font-size: 26px;
40
+ }
41
+ }
42
+
43
+ .h3 {
44
+ margin: 0;
45
+ font-size: 18px;
46
+ line-height: 1.35;
47
+ }
48
+
49
+ .subhead {
50
+ margin: 12px 0 0;
51
+ max-width: 50rem;
52
+ font-size: var(--mantine-font-size-md);
53
+ line-height: 1.55;
54
+ color: var(--site-muted);
55
+ }
56
+
57
+ .body {
58
+ margin: 8px 0 0;
59
+ font-size: var(--mantine-font-size-md);
60
+ line-height: 1.6;
61
+ color: var(--site-muted);
62
+ }
63
+
64
+ .small {
65
+ margin: 0;
66
+ font-size: var(--mantine-font-size-sm);
67
+ line-height: 1.55;
68
+ color: var(--site-muted);
69
+ }
70
+
71
+ .actions {
72
+ display: flex;
73
+ flex-wrap: wrap;
74
+ gap: 12px;
75
+ margin-top: 28px;
76
+ }
77
+
78
+ /* Hero buttons: Mantine's md height with a 15 px label, so the wireframe's pair fits on one row beside a screen. */
79
+ .heroButton {
80
+ padding-inline: 20px;
81
+ font-size: 15px;
82
+ }
83
+
84
+ .card {
85
+ padding: 24px;
86
+ border: 1px solid var(--site-border);
87
+ border-radius: var(--mantine-radius-lg);
88
+ background: var(--mantine-color-white);
89
+ }
90
+
91
+ .eyebrow {
92
+ margin: 0 0 6px;
93
+ font-size: var(--mantine-font-size-xs);
94
+ color: var(--site-muted);
95
+ }
@@ -0,0 +1,52 @@
1
+ .section {
2
+ padding-block: var(--site-section-space);
3
+ border-bottom: 1px solid var(--site-border);
4
+
5
+ &[data-tone="band"] {
6
+ background: var(--site-paper);
7
+ }
8
+
9
+ &[data-size="hero"] {
10
+ padding-block: calc(var(--site-section-space) * 0.9) var(--site-section-space);
11
+ }
12
+
13
+ &:last-child {
14
+ border-bottom: 0;
15
+ }
16
+ }
17
+
18
+ .container {
19
+ max-width: var(--site-max-width);
20
+ margin-inline: auto;
21
+ padding-inline: var(--site-gutter);
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ .text,
26
+ .media {
27
+ min-width: 0;
28
+ }
29
+
30
+ .split {
31
+ display: grid;
32
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
33
+ gap: 48px;
34
+ align-items: center;
35
+
36
+ &[data-align="start"] {
37
+ align-items: start;
38
+ }
39
+
40
+ &[data-media-first] .media {
41
+ order: -1;
42
+ }
43
+
44
+ @media (max-width: 62em) {
45
+ grid-template-columns: minmax(0, 1fr);
46
+ gap: 32px;
47
+
48
+ &[data-media-first] .media {
49
+ order: 0;
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import classes from "./Section.module.css";
4
+
5
+ type SectionProps = {
6
+ children: ReactNode;
7
+ /** `band` is the tinted background the pages alternate with white. */
8
+ tone?: "plain" | "band";
9
+ /** `hero` is the first section of a page, with more space above. */
10
+ size?: "hero" | "default";
11
+ /** The id of the section's heading, which names it for assistive technology. */
12
+ labelledBy?: string;
13
+ };
14
+
15
+ /** A full-width band with the page container (`--site-max-width` wide with a `--site-gutter` either side). */
16
+ export const Section = ({ children, tone = "plain", size = "default", labelledBy }: SectionProps) => (
17
+ <section className={classes.section} data-tone={tone} data-size={size} aria-labelledby={labelledBy}>
18
+ <div className={classes.container}>{children}</div>
19
+ </section>
20
+ );
21
+
22
+ /** Two columns that stack on phones and tablets. `mediaFirst` puts the media on the left from md up. */
23
+ export const Split = ({
24
+ children,
25
+ media,
26
+ mediaFirst = false,
27
+ align = "center",
28
+ }: {
29
+ children: ReactNode;
30
+ media: ReactNode;
31
+ mediaFirst?: boolean;
32
+ align?: "center" | "start";
33
+ }) => (
34
+ <div className={classes.split} data-media-first={mediaFirst || undefined} data-align={align}>
35
+ <div className={classes.text}>{children}</div>
36
+ <div className={classes.media}>{media}</div>
37
+ </div>
38
+ );
@@ -0,0 +1,34 @@
1
+ .footer {
2
+ border-top: 1px solid var(--site-border);
3
+ background: var(--mantine-color-white);
4
+ }
5
+
6
+ .inner {
7
+ display: grid;
8
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2.4fr);
9
+ gap: 16px 48px;
10
+ max-width: var(--site-max-width);
11
+ margin-inline: auto;
12
+ padding: 32px var(--site-gutter) 48px;
13
+ box-sizing: border-box;
14
+ font-size: var(--mantine-font-size-xs);
15
+ line-height: 1.6;
16
+ color: var(--site-muted);
17
+
18
+ @media (max-width: 48em) {
19
+ grid-template-columns: minmax(0, 1fr);
20
+ }
21
+
22
+ & p {
23
+ margin: 0;
24
+ }
25
+
26
+ & a:not([class]) {
27
+ color: var(--site-muted);
28
+ text-decoration-thickness: 1px;
29
+ }
30
+
31
+ & a:not([class]):hover {
32
+ color: var(--site-text);
33
+ }
34
+ }
@@ -0,0 +1,31 @@
1
+ import type { SiteConfig } from "../config";
2
+ import classes from "./SiteFooter.module.css";
3
+
4
+ const list = new Intl.ListFormat("en-US", { type: "conjunction" });
5
+
6
+ /**
7
+ * The footer: who makes the product, its links, the family's no-trackers line, and the trademark disclaimer for the
8
+ * vendors the site names. A server component, like the header.
9
+ */
10
+ export const SiteFooter = ({ site }: { site: SiteConfig }) => (
11
+ <footer className={classes.footer}>
12
+ <div className={classes.inner}>
13
+ <p className={classes.maker}>{site.footer.maker}</p>
14
+ <p className={classes.links}>
15
+ {site.footer.links.map((link) => (
16
+ <span key={link.href}>
17
+ <a href={link.href}>{link.label}</a> ·{" "}
18
+ </span>
19
+ ))}
20
+ No trackers
21
+ {site.footer.trademarks.length > 0 && (
22
+ <>
23
+ {" "}
24
+ · {list.format(site.footer.trademarks)} are trademarks of their owners; {site.name} is not affiliated with
25
+ them
26
+ </>
27
+ )}
28
+ </p>
29
+ </div>
30
+ </footer>
31
+ );
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { SiteConfig } from "../config";
4
+ import { SiteFooter } from "./SiteFooter";
5
+ import { SiteHeader } from "./SiteHeader";
6
+
7
+ /**
8
+ * Every page's frame: skip link, header with the page's section marked current, main, and footer. Pages render it
9
+ * themselves because a layout cannot know which section is current without a client component.
10
+ */
11
+ export const SiteFrame = <Key extends string>({
12
+ site,
13
+ active,
14
+ children,
15
+ }: {
16
+ site: SiteConfig<Key>;
17
+ active?: Key | undefined;
18
+ children: ReactNode;
19
+ }) => (
20
+ <>
21
+ <a href="#main" className="skip-link">
22
+ Skip to content
23
+ </a>
24
+ <SiteHeader site={site} active={active} />
25
+ <main id="main">{children}</main>
26
+ <SiteFooter site={site} />
27
+ </>
28
+ );
@@ -0,0 +1,143 @@
1
+ /*
2
+ * Colors come from the theme's primary scale (--mantine-primary-color-*), so the header is the product's color
3
+ * without naming it; the shadow under the folded menu is the site's --site-shadow, a tint of its darkest color.
4
+ */
5
+ .header {
6
+ position: relative;
7
+ border-bottom: 1px solid var(--site-border);
8
+ background: var(--mantine-color-white);
9
+ }
10
+
11
+ .inner {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: 24px;
15
+ max-width: var(--site-max-width);
16
+ min-height: 64px;
17
+ margin-inline: auto;
18
+ padding-inline: var(--site-gutter);
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ .home {
23
+ display: inline-flex;
24
+ flex: none;
25
+ text-decoration: none;
26
+ }
27
+
28
+ .nav {
29
+ display: flex;
30
+ flex: 1;
31
+ justify-content: center;
32
+ gap: 4px;
33
+
34
+ @media (max-width: 62em) {
35
+ display: none;
36
+ }
37
+ }
38
+
39
+ .link {
40
+ padding: 6px 10px;
41
+ border-radius: var(--mantine-radius-sm);
42
+ font-size: var(--mantine-font-size-sm);
43
+ line-height: 1.4;
44
+ color: var(--site-text);
45
+ text-decoration: none;
46
+ white-space: nowrap;
47
+
48
+ &:hover {
49
+ background: var(--mantine-primary-color-0);
50
+ }
51
+
52
+ &[aria-current="page"] {
53
+ background: var(--mantine-primary-color-0);
54
+ font-weight: 700;
55
+ }
56
+ }
57
+
58
+ .actions {
59
+ display: flex;
60
+ flex: none;
61
+ align-items: center;
62
+ gap: 8px;
63
+ margin-inline-start: auto;
64
+ }
65
+
66
+ /* Matches Mantine's filled Button at size sm, which the header cannot use (it is a client component). */
67
+ .primary {
68
+ display: inline-flex;
69
+ align-items: center;
70
+ height: 36px;
71
+ padding-inline: 16px;
72
+ border-radius: var(--mantine-radius-default);
73
+ background: var(--mantine-primary-color-filled);
74
+ color: var(--mantine-color-white);
75
+ font-size: var(--mantine-font-size-sm);
76
+ font-weight: 600;
77
+ text-decoration: none;
78
+ white-space: nowrap;
79
+
80
+ &:hover {
81
+ background: var(--mantine-primary-color-filled-hover);
82
+ }
83
+ }
84
+
85
+ /* An action that leaves the bar on narrow screens; the folded menu lists it instead. */
86
+ @media (max-width: 62em) {
87
+ .actions [data-hide-below="md"] {
88
+ display: none;
89
+ }
90
+ }
91
+
92
+ @media (max-width: 36em) {
93
+ .actions [data-hide-below="sm"] {
94
+ display: none;
95
+ }
96
+ }
97
+
98
+ .menu {
99
+ display: none;
100
+
101
+ @media (max-width: 62em) {
102
+ display: block;
103
+ }
104
+ }
105
+
106
+ .menuButton {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ width: 40px;
111
+ height: 40px;
112
+ border-radius: var(--mantine-radius-default);
113
+ color: var(--site-text);
114
+ cursor: pointer;
115
+ list-style: none;
116
+
117
+ &::-webkit-details-marker {
118
+ display: none;
119
+ }
120
+
121
+ &:hover {
122
+ background: var(--mantine-primary-color-0);
123
+ }
124
+ }
125
+
126
+ .menuPanel {
127
+ position: absolute;
128
+ inset-inline: 0;
129
+ top: 100%;
130
+ z-index: 20;
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: 2px;
134
+ padding: 8px var(--site-gutter) 16px;
135
+ border-bottom: 1px solid var(--site-border);
136
+ background: var(--mantine-color-white);
137
+ box-shadow: 0 8px 16px var(--site-shadow, rgb(0 0 0 / 8%));
138
+
139
+ & .link {
140
+ padding: 10px 8px;
141
+ font-size: var(--mantine-font-size-md);
142
+ }
143
+ }
@@ -0,0 +1,59 @@
1
+ import type { HeaderAction, SiteConfig } from "../config";
2
+ import classes from "./SiteHeader.module.css";
3
+
4
+ const links = (site: SiteConfig, active: string | undefined) =>
5
+ site.nav.map((item) => (
6
+ <a key={item.key} href={item.href} className={classes.link} aria-current={item.key === active ? "page" : undefined}>
7
+ {item.label}
8
+ </a>
9
+ ));
10
+
11
+ const action = ({ label, href, kind, hideBelow }: HeaderAction) => (
12
+ <a key={href} href={href} className={kind === "primary" ? classes.primary : classes.link} data-hide-below={hideBelow}>
13
+ {label}
14
+ </a>
15
+ );
16
+
17
+ /**
18
+ * The header: lockup, the sections, and the actions. A server component with plain links, so a page that ships no
19
+ * client code can carry it; below the md breakpoint the sections fold into a <details> menu, which needs no script,
20
+ * and any action hidden at some width is listed there too.
21
+ */
22
+ export const SiteHeader = <Key extends string>({
23
+ site,
24
+ active,
25
+ }: {
26
+ site: SiteConfig<Key>;
27
+ active?: Key | undefined;
28
+ }) => (
29
+ <header className={classes.header}>
30
+ <div className={classes.inner}>
31
+ <a href="/" className={classes.home}>
32
+ {site.lockup}
33
+ </a>
34
+ <nav aria-label="Main" className={classes.nav}>
35
+ {links(site, active)}
36
+ </nav>
37
+ <div className={classes.actions}>
38
+ {site.actions.map(action)}
39
+ <details className={classes.menu}>
40
+ <summary className={classes.menuButton} aria-label="Menu">
41
+ <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
42
+ <path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
43
+ </svg>
44
+ </summary>
45
+ <nav aria-label="Main" className={classes.menuPanel}>
46
+ {links(site, active)}
47
+ {site.actions
48
+ .filter((item) => item.hideBelow !== undefined)
49
+ .map((item) => (
50
+ <a key={item.href} href={item.href} className={classes.link}>
51
+ {item.label}
52
+ </a>
53
+ ))}
54
+ </nav>
55
+ </details>
56
+ </div>
57
+ </div>
58
+ </header>
59
+ );
@@ -0,0 +1,126 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { ImageResponse } from "next/og";
5
+
6
+ import { type Card, cardSegments, socialCardSize } from "./cards";
7
+ import type { SiteConfig } from "./config";
8
+
9
+ /*
10
+ * A page's social card: 1200 × 630, the brand's ground, the product's mark and wordmark, the headline, one sentence,
11
+ * and the domain in the accent, drawn by next/og at build (the product's app/og/[...card]/route.tsx).
12
+ */
13
+
14
+ const weights = [400, 600, 700] as const;
15
+ const fontCache = path.join(process.cwd(), ".next", "cache", "coral-reef", "fonts");
16
+
17
+ // next/og needs TrueType; Google Fonts serves it to clients that are not browsers. Kept between builds with the
18
+ // rest of .next/cache.
19
+ const loadFont = async (family: string, weight: number) => {
20
+ const slug = family.toLowerCase().replaceAll(" ", "-");
21
+ const file = path.join(fontCache, `${slug}-${weight}.ttf`);
22
+ const cached = await readFile(file).catch(() => undefined);
23
+ if (cached) {
24
+ return cached;
25
+ }
26
+ const query = family.replaceAll(" ", "+");
27
+ const css = await (await fetch(`https://fonts.googleapis.com/css2?family=${query}:wght@${weight}`)).text();
28
+ const url = /src:\s*url\(([^)]+\.ttf)\)/.exec(css)?.[1];
29
+ if (!url) {
30
+ throw new Error(`Google Fonts returned no TrueType ${family} ${weight}`);
31
+ }
32
+ const data = Buffer.from(await (await fetch(url)).arrayBuffer());
33
+ await mkdir(fontCache, { recursive: true });
34
+ await writeFile(file, data);
35
+ return data;
36
+ };
37
+
38
+ type Font = { name: string; data: Buffer; weight: (typeof weights)[number]; style: "normal" };
39
+ const fonts = new Map<string, Promise<Font[]>>();
40
+
41
+ const loadFonts = (family: string) => {
42
+ let loading = fonts.get(family);
43
+ if (!loading) {
44
+ loading = Promise.all(
45
+ weights.map(async (weight) => ({
46
+ name: family,
47
+ data: await loadFont(family, weight),
48
+ weight,
49
+ style: "normal" as const,
50
+ })),
51
+ );
52
+ fonts.set(family, loading);
53
+ }
54
+ return loading;
55
+ };
56
+
57
+ /** The card's markup, which next/og draws; a function of its own so a test can read it without a renderer. */
58
+ export const socialCardElement = (site: SiteConfig, { headline, sentence }: Card) => {
59
+ const {
60
+ background,
61
+ text,
62
+ muted,
63
+ accent,
64
+ mark,
65
+ domain = new URL(site.url).host,
66
+ font = "Noto Sans",
67
+ } = site.socialCard;
68
+ return (
69
+ <div
70
+ style={{
71
+ width: "100%",
72
+ height: "100%",
73
+ display: "flex",
74
+ flexDirection: "column",
75
+ padding: "96px 96px 64px",
76
+ background,
77
+ fontFamily: font,
78
+ }}
79
+ >
80
+ <div style={{ display: "flex", alignItems: "center", gap: 28 }}>
81
+ {mark}
82
+ <div style={{ fontSize: 60, fontWeight: 700, letterSpacing: -0.6, color: text }}>{site.name}</div>
83
+ </div>
84
+ <div style={{ display: "flex", flexDirection: "column", marginTop: "auto" }}>
85
+ <div
86
+ style={{ maxWidth: 920, fontSize: 52, fontWeight: 700, lineHeight: 1.25, letterSpacing: -0.5, color: text }}
87
+ >
88
+ {headline}
89
+ </div>
90
+ {sentence && (
91
+ <div style={{ marginTop: 20, fontSize: 30, lineHeight: 1.35, color: muted, opacity: 0.85 }}>{sentence}</div>
92
+ )}
93
+ <div style={{ marginTop: 40, fontSize: 24, fontWeight: 600, color: accent }}>{domain}</div>
94
+ </div>
95
+ </div>
96
+ );
97
+ };
98
+
99
+ /** The card as a PNG response. */
100
+ export const socialCard = async (site: SiteConfig, card: Card) =>
101
+ new ImageResponse(socialCardElement(site, card), {
102
+ ...socialCardSize,
103
+ fonts: await loadFonts(site.socialCard.font ?? "Noto Sans"),
104
+ });
105
+
106
+ /**
107
+ * The two exports a static route needs to write every page's card at build (/og/pricing.png): the product's
108
+ * route.tsx keeps `dynamic = "force-static"` and `dynamicParams = false` as literals, which Next reads statically,
109
+ * and takes these from here. `more` adds cards for pages outside the config, such as docs pages titled at build.
110
+ */
111
+ export const socialCardRoute = (site: SiteConfig, more?: () => Promise<readonly (readonly [string, Card])[]>) => {
112
+ const allCards = async (): Promise<Map<string, Card>> => {
113
+ const entries: (readonly [string, Card])[] = [...Object.entries(site.cards), ...((await more?.()) ?? [])];
114
+ return new Map(entries.map(([pagePath, card]) => [cardSegments(pagePath).join("/"), card]));
115
+ };
116
+ return {
117
+ generateStaticParams: async () => [...(await allCards()).keys()].map((key) => ({ card: key.split("/") })),
118
+ GET: async (_request: Request, { params }: { params: Promise<{ card: string[] }> }) => {
119
+ const card = (await allCards()).get((await params).card.join("/"));
120
+ if (!card) {
121
+ return new Response("Not found", { status: 404 });
122
+ }
123
+ return socialCard(site, card);
124
+ },
125
+ };
126
+ };
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * The rule both sites hold: the frame every page shares renders as server components only, with no client
6
+ * components, so a page that needs no script ships none. `clientReach` walks an entry's imports within the site and
7
+ * names each "use client" module and each import of a package that is one. A product's test asserts it is empty
8
+ * for the root layout, the 404 page and anything else that must stay script-free, and not empty for a page that
9
+ * uses Mantine, so the walk is known to see.
10
+ */
11
+ export const clientPackages = [/^@mantine\//, /^next\/link$/, /^next\/image$/, /^next\/script$/, /^next\/dynamic$/];
12
+
13
+ export type ClientReachOptions = {
14
+ /** The app's root, which the alias resolves against. */
15
+ root: string;
16
+ /** The import alias for the root; `@/` as both sites have it. */
17
+ alias?: string;
18
+ };
19
+
20
+ const resolveLocal = (specifier: string, from: string, { root, alias = "@/" }: ClientReachOptions) => {
21
+ const base = specifier.startsWith(alias)
22
+ ? path.join(root, specifier.slice(alias.length))
23
+ : path.resolve(path.dirname(from), specifier);
24
+ const stripped = base.replace(/\.tsx?$/, "");
25
+ const candidates = [
26
+ base,
27
+ `${stripped}.ts`,
28
+ `${stripped}.tsx`,
29
+ path.join(base, "index.ts"),
30
+ path.join(base, "index.tsx"),
31
+ ];
32
+ return candidates.find((candidate) => existsSync(candidate) && /\.tsx?$/.test(candidate));
33
+ };
34
+
35
+ /** Every module specifier a file imports, static or dynamic. */
36
+ export const importsOf = (file: string) =>
37
+ [...readFileSync(file, "utf8").matchAll(/(?:from\s+|import\s*\(?\s*)["']([^"']+)["']/g)].map(
38
+ (match) => match[1] ?? "",
39
+ );
40
+
41
+ export const isClientModule = (file: string) => /^\s*["']use client["']/.test(readFileSync(file, "utf8"));
42
+
43
+ /** Every problem reachable from `entry`: a client module, or an import of a client package. */
44
+ export const clientReach = (entry: string, options: ClientReachOptions): string[] => {
45
+ const problems: string[] = [];
46
+ const seen = new Set<string>();
47
+ const visit = (file: string) => {
48
+ seen.add(file);
49
+ const name = path.relative(options.root, file);
50
+ if (isClientModule(file)) {
51
+ problems.push(`${name} is a client module`);
52
+ }
53
+ const specifiers = importsOf(file).filter((specifier) => !specifier.endsWith(".css"));
54
+ const clientImports = specifiers.filter((specifier) => clientPackages.some((pattern) => pattern.test(specifier)));
55
+ problems.push(...clientImports.map((specifier) => `${name} imports ${specifier}`));
56
+ const alias = options.alias ?? "@/";
57
+ const local = specifiers
58
+ .filter((specifier) => specifier.startsWith(".") || specifier.startsWith(alias))
59
+ .map((specifier) => resolveLocal(specifier, file, options))
60
+ .filter((target): target is string => target !== undefined && !seen.has(target));
61
+ for (const target of local) {
62
+ visit(target);
63
+ }
64
+ };
65
+ visit(entry);
66
+ return problems;
67
+ };