Skip to content

Commit edaef75

Browse files
authored
feat(integrations): add the integrations directory pages (#103)
* feat(integrations): sync the integrations registry into the site * feat(integrations): add the integrations directory pages
1 parent e8c2c86 commit edaef75

80 files changed

Lines changed: 3692 additions & 28 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Support\Content\ContentRepository;
6+
use App\Support\Content\IntegrationCatalog;
7+
use App\Support\Localization\Locales;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Arr;
10+
use Illuminate\Support\Carbon;
11+
use Illuminate\Support\Facades\App;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
use InvalidArgumentException;
15+
16+
/**
17+
* @phpstan-type Icon array{src: string, width: int, height: int}
18+
* @phpstan-type Summary array{slug: string, name: string, publisher: string, tier: string, categories: list<string>, platforms: list<string>, closedSource: bool, tagline: string, keywords: list<string>, icon: Icon|null}
19+
* @phpstan-type Filters array{q: string|null, category: string|null, platform: string|null, tier: string|null}
20+
*/
21+
class IntegrationController extends Controller
22+
{
23+
private const array TIERS = ['official', 'partner', 'community'];
24+
25+
// The registry names the OS a TablePro app runs on; the site names the app.
26+
private const array PLATFORMS = ['macos' => 'mac', 'ios' => 'ios', 'ipados' => 'ios'];
27+
28+
private const array NETWORK = ['none', 'local', 'internet'];
29+
30+
private const array PAYMENT = ['free', 'optional', 'paid'];
31+
32+
private const int QUERY_LENGTH = 100;
33+
34+
private const string IMAGES = '/images/integrations/';
35+
36+
public function index(Request $request, ContentRepository $content, IntegrationCatalog $catalog): Response
37+
{
38+
$locale = App::getLocale();
39+
$page = $content->page('integrations/index', $locale);
40+
$listed = $this->listed($catalog, $page, $content, $locale);
41+
42+
return Inertia::render('Integrations/Index', [
43+
'content' => Arr::except($page, ['taglines', 'show']),
44+
'integrations' => $listed,
45+
'filters' => $this->filters($request, $listed),
46+
]);
47+
}
48+
49+
public function show(ContentRepository $content, IntegrationCatalog $catalog, string $slug): Response
50+
{
51+
$integration = $catalog->find($slug);
52+
53+
abort_if($integration === null, 404);
54+
55+
$locale = App::getLocale();
56+
$page = $content->page('integrations/index', $locale);
57+
58+
return Inertia::render('Integrations/Show', [
59+
'content' => Arr::only($page, ['labels', 'show']),
60+
'integration' => $this->detail($integration, $page, $catalog),
61+
'dates' => [
62+
'added' => $this->date($integration['addedAt'], $locale),
63+
'verified' => $this->date($integration['lastVerifiedAt'], $locale),
64+
'archived' => $this->date($integration['status']['since'] ?? null, $locale),
65+
],
66+
]);
67+
}
68+
69+
/**
70+
* @param array<string, mixed> $page
71+
* @return list<Summary>
72+
*/
73+
private function listed(IntegrationCatalog $catalog, array $page, ContentRepository $content, string $locale): array
74+
{
75+
$default = $locale === Locales::default();
76+
$taglines = $this->taglines($page);
77+
$sources = $default ? [] : $this->taglines($content->page('integrations/index', Locales::default()));
78+
$categories = $this->labelled($page, 'labels.categories');
79+
$listed = [];
80+
81+
foreach ($catalog->all() as $integration) {
82+
$slug = $integration['slug'];
83+
84+
if (($integration['status']['state'] ?? null) !== 'active') {
85+
continue;
86+
}
87+
88+
// Another language lists an entry only while its tagline there was translated from the current summary.
89+
if ($default) {
90+
$tagline = $integration['summary'];
91+
} elseif (($sources[$slug] ?? null) === $integration['summary'] && isset($taglines[$slug])) {
92+
$tagline = $taglines[$slug];
93+
} else {
94+
continue;
95+
}
96+
97+
$listed[] = [
98+
'slug' => $slug,
99+
'name' => $integration['name'],
100+
'publisher' => $integration['publisher']['name'],
101+
'tier' => $this->tier($integration),
102+
'categories' => $this->known($integration['categories'], $categories),
103+
'platforms' => $this->platforms($integration['platforms']),
104+
'closedSource' => $integration['closedSource'] === true,
105+
'tagline' => $tagline,
106+
'keywords' => array_values(array_filter($integration['keywords'] ?? [], 'is_string')),
107+
'icon' => $this->image($integration['icon']['128'] ?? null),
108+
];
109+
}
110+
111+
$rank = array_flip(self::TIERS);
112+
113+
usort($listed, fn(array $a, array $b): int => [$rank[$a['tier']], strtolower($a['name'])] <=> [$rank[$b['tier']], strtolower($b['name'])]);
114+
115+
return $listed;
116+
}
117+
118+
/**
119+
* @param array<string, mixed> $page
120+
* @return array<string, string>
121+
*/
122+
private function taglines(array $page): array
123+
{
124+
$taglines = is_array($page['taglines'] ?? null) ? $page['taglines'] : [];
125+
126+
return array_filter($taglines, fn(mixed $tagline): bool => is_string($tagline) && trim($tagline) !== '');
127+
}
128+
129+
/**
130+
* @param list<Summary> $listed
131+
* @return Filters
132+
*/
133+
private function filters(Request $request, array $listed): array
134+
{
135+
$value = function (string $key, string $field) use ($request, $listed): ?string {
136+
$value = $request->query($key);
137+
$present = array_merge(...array_map(fn(array $entry): array => (array) $entry[$field], $listed));
138+
139+
return is_string($value) && in_array($value, $present, true) ? $value : null;
140+
};
141+
142+
$q = $request->query('q');
143+
$q = is_string($q) ? trim(mb_substr(trim($q), 0, self::QUERY_LENGTH)) : '';
144+
145+
return [
146+
'q' => $q !== '' ? $q : null,
147+
'category' => $value('category', 'categories'),
148+
'platform' => $value('platform', 'platforms'),
149+
'tier' => $value('tier', 'tier'),
150+
];
151+
}
152+
153+
/**
154+
* @param array<string, mixed> $integration
155+
* @param array<string, mixed> $page
156+
* @return array<string, mixed>
157+
*/
158+
private function detail(array $integration, array $page, IntegrationCatalog $catalog): array
159+
{
160+
$status = $integration['status'];
161+
$disclosures = $integration['disclosures'];
162+
$install = $integration['install'];
163+
$host = $integration['host'] ?? null;
164+
$versions = $integration['minTableProVersion'] ?? [];
165+
$replacement = is_string($status['replacement'] ?? null) ? $catalog->find($status['replacement']) : null;
166+
$type = in_array($install['type'], $this->labelled($page, 'show.install'), true) ? $install['type'] : 'page';
167+
$data = $this->labelled($page, 'show.uses.data');
168+
169+
return [
170+
'slug' => $integration['slug'],
171+
'name' => $integration['name'],
172+
'summary' => $integration['summary'],
173+
'description' => array_values(array_filter(array_map('trim', preg_split('/\n\s*\n/', $integration['description']) ?: []))),
174+
'tier' => $this->tier($integration),
175+
'archived' => $status['state'] !== 'active' ? [
176+
'reason' => in_array($status['reason'] ?? null, $this->labelled($page, 'show.archived.reasons'), true) ? $status['reason'] : null,
177+
'note' => $this->text($status['note'] ?? null),
178+
'replacement' => $replacement !== null ? ['slug' => $replacement['slug'], 'name' => $replacement['name']] : null,
179+
] : null,
180+
'publisher' => ['name' => $integration['publisher']['name'], 'url' => $this->https($integration['publisher']['url'] ?? null)],
181+
'categories' => $this->known($integration['categories'], $this->labelled($page, 'labels.categories')),
182+
'platforms' => $this->platforms($integration['platforms']),
183+
'versions' => [
184+
'mac' => $this->text($versions['macos'] ?? null),
185+
'ios' => $this->text($versions['ios'] ?? $versions['ipados'] ?? null),
186+
],
187+
'host' => is_array($host) ? [
188+
'name' => $host['name'],
189+
'url' => $this->https($host['url'] ?? null),
190+
'minVersion' => $this->text($host['minVersion'] ?? null),
191+
'note' => $this->text($host['note'] ?? null),
192+
] : null,
193+
'closedSource' => $integration['closedSource'] === true,
194+
'license' => $this->text($integration['license'] ?? null),
195+
'source' => $this->https($integration['source']['url'] ?? null),
196+
'install' => [
197+
'type' => $type,
198+
'url' => $this->https($install['url']),
199+
'command' => $type === 'homebrew' ? $this->brew($install) : null,
200+
],
201+
'surfaces' => $this->known($integration['surfaces'], $this->labelled($page, 'show.uses.surface')),
202+
'reads' => $this->known($disclosures['reads'] ?? [], $data),
203+
'writes' => $this->known($disclosures['writes'] ?? [], $data),
204+
'accessNote' => $this->text($disclosures['accessNote'] ?? null),
205+
'lowersSafeMode' => ($disclosures['lowersSafeMode'] ?? false) === true,
206+
'safeModeNote' => $this->text($disclosures['safeModeNote'] ?? null),
207+
'network' => in_array($disclosures['network'] ?? null, self::NETWORK, true) ? $disclosures['network'] : null,
208+
'networkNote' => $this->text($disclosures['networkNote'] ?? null),
209+
'account' => ($disclosures['account'] ?? false) === true,
210+
'payment' => in_array($disclosures['payment'] ?? null, self::PAYMENT, true) ? $disclosures['payment'] : null,
211+
'paymentNote' => $this->text($disclosures['paymentNote'] ?? null),
212+
'links' => [
213+
'docs' => $this->https($integration['links']['docs'] ?? null),
214+
'issues' => $this->https($integration['links']['issues'] ?? $integration['links']['support'] ?? null),
215+
'privacy' => $this->https($integration['links']['privacy'] ?? null),
216+
],
217+
'icon' => $this->image($integration['icon']['128'] ?? null),
218+
'screenshots' => $this->screenshots($integration['screenshots']),
219+
];
220+
}
221+
222+
/**
223+
* @param list<mixed> $screenshots
224+
* @return list<array{src: string, width: int, height: int, alt: string}>
225+
*/
226+
private function screenshots(array $screenshots): array
227+
{
228+
$shots = [];
229+
230+
foreach ($screenshots as $shot) {
231+
$image = $this->image($shot);
232+
233+
if ($image !== null && is_string($shot['alt'] ?? null)) {
234+
$shots[] = [...$image, 'alt' => $shot['alt']];
235+
}
236+
}
237+
238+
return $shots;
239+
}
240+
241+
/**
242+
* @param array<string, mixed> $integration
243+
*/
244+
private function tier(array $integration): string
245+
{
246+
// An unknown tier gets the most cautious treatment: the disclaimer and rel="ugc nofollow".
247+
return in_array($integration['tier'], self::TIERS, true) ? $integration['tier'] : 'community';
248+
}
249+
250+
/**
251+
* @param list<mixed> $platforms
252+
* @return list<string>
253+
*/
254+
private function platforms(array $platforms): array
255+
{
256+
$apps = array_map(fn(mixed $platform): ?string => is_string($platform) ? (self::PLATFORMS[$platform] ?? null) : null, $platforms);
257+
258+
return array_values(array_intersect(array_unique(self::PLATFORMS), $apps));
259+
}
260+
261+
/**
262+
* @param array<string, mixed> $page
263+
* @return list<string>
264+
*/
265+
private function labelled(array $page, string $key): array
266+
{
267+
// A value the registry adds later stays off the page until the content file labels it.
268+
$labels = Arr::get($page, $key);
269+
270+
return is_array($labels) ? array_map('strval', array_keys($labels)) : [];
271+
}
272+
273+
/**
274+
* @param list<mixed> $values
275+
* @param list<string> $known
276+
* @return list<string>
277+
*/
278+
private function known(array $values, array $known): array
279+
{
280+
return array_values(array_intersect($known, $values));
281+
}
282+
283+
/**
284+
* @return Icon|null
285+
*/
286+
private function image(mixed $image): ?array
287+
{
288+
if (! is_array($image) || ! is_string($image['file'] ?? null) || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{12}\.png$/', $image['file']) !== 1) {
289+
return null;
290+
}
291+
292+
return ['src' => self::IMAGES . $image['file'], 'width' => (int) $image['width'], 'height' => (int) $image['height']];
293+
}
294+
295+
/**
296+
* @param array<string, mixed> $install
297+
*/
298+
private function brew(array $install): ?string
299+
{
300+
$token = $install['token'] ?? null;
301+
302+
if (! is_string($token) || preg_match('/^[a-z0-9][a-z0-9@+._-]{0,99}$/', $token) !== 1) {
303+
return null;
304+
}
305+
306+
return ($install['kind'] ?? null) === 'cask' ? "brew install --cask {$token}" : "brew install {$token}";
307+
}
308+
309+
private function https(mixed $url): ?string
310+
{
311+
if (! is_string($url) || ! str_starts_with($url, 'https://') || filter_var($url, FILTER_VALIDATE_URL) === false) {
312+
return null;
313+
}
314+
315+
return parse_url($url, PHP_URL_USER) === null ? $url : null;
316+
}
317+
318+
private function text(mixed $value): ?string
319+
{
320+
return is_string($value) && trim($value) !== '' ? $value : null;
321+
}
322+
323+
private function date(mixed $value, string $locale): ?string
324+
{
325+
if (! is_string($value) || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
326+
return null;
327+
}
328+
329+
try {
330+
$date = Carbon::createFromFormat('!Y-m-d', $value);
331+
} catch (InvalidArgumentException) {
332+
return null;
333+
}
334+
335+
return $date !== null && $date->format('Y-m-d') === $value ? $date->locale($locale)->isoFormat('LL') : null;
336+
}
337+
}

‎app/Providers/SeoServiceProvider.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Support\Content\ContentRepository;
66
use App\Support\Seo\BlogPosts;
77
use App\Support\Seo\ContentCollection;
8+
use App\Support\Seo\IntegrationPages;
89
use App\Support\Seo\LastModified;
910
use App\Support\Seo\LegalPages;
1011
use App\Support\Seo\OgFonts;
@@ -29,6 +30,7 @@
2930
* 2. `ContentCollection`: features, databases and comparisons, with their hubs.
3031
* 3. `LegalPages`: privacy, terms, refund policy.
3132
* 4. `BlogPosts`: one page per post, per language it is written in.
33+
* 5. `IntegrationPages`: one English page per entry in `integrations.json`.
3234
*
3335
* Every family is wrapped in `WithoutRetiredPaths`, so a URL the redirect map
3436
* answers is never a page, whichever family still lists it.
@@ -69,6 +71,7 @@ public function register(): void
6971
new ContentCollection($content),
7072
$app->make(LegalPages::class),
7173
$app->make(BlogPosts::class),
74+
$app->make(IntegrationPages::class),
7275
];
7376

7477
return new PageRegistry(array_map(

‎app/Support/Localization/LocaleSwitcher.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ final class LocaleSwitcher
3636
'compare' => 'landing.compare.index',
3737
'database' => 'landing.databases.index',
3838
'feature' => 'landing.features.index',
39+
'integration' => 'landing.integrations.index',
3940
];
4041

4142
public function __construct(

‎app/Support/Seo/ContentCollection.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
/**
1212
* The page families built from content collections: features, databases and
13-
* comparisons, each with its hub.
13+
* comparisons, each with its hub, and the integrations hub.
1414
*
1515
* A page renders in a locale when `resources/data/content/{locale}/{family}/{slug}.json`
1616
* exists (`index.json` for a hub), and is indexable there unless that file
@@ -37,6 +37,7 @@ final class ContentCollection implements PageFamily
3737
'landing.features.index' => ['features', 'feature', ['resources/data/paid-features.json', 'resources/data/facts.json']],
3838
'landing.databases.index' => ['databases', 'database', ['resources/data/engines.json']],
3939
'landing.compare.index' => ['compare', 'compare', ['resources/data/comparisons.json']],
40+
'landing.integrations.index' => ['integrations', 'integration', ['resources/data/integrations.json']],
4041
];
4142

4243
/**

0 commit comments

Comments
 (0)