@atelic-action/artifact 1.3.0 → 3.0.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.
- package/README.md +6 -5
- package/package.json +3 -3
- package/src/documents/Read.tsx +203 -302
- package/src/documents/Writeup.tsx +93 -72
- package/src/documents/documents.css +104 -415
package/src/documents/Read.tsx
CHANGED
|
@@ -1,47 +1,45 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type ArtifactLink,
|
|
3
3
|
type ArtifactParagraph,
|
|
4
|
-
GradeChip,
|
|
5
|
-
gradeLabel,
|
|
6
4
|
isEmpty,
|
|
7
5
|
newTabProps,
|
|
8
6
|
Paragraph,
|
|
9
7
|
Runs,
|
|
10
|
-
Shot,
|
|
11
8
|
SignOff,
|
|
12
9
|
type WriteupGrade,
|
|
13
10
|
type WriteupPerson,
|
|
14
|
-
type WriteupShot,
|
|
15
11
|
} from "@atelic-action/ui/artifact";
|
|
12
|
+
import { IconList, ReportCard, type Tone } from "@atelic-action/ui/components";
|
|
16
13
|
import { ArrowUpRight, Check } from "lucide-react";
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import { Topic, type WriteupTopic } from "./Writeup";
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* The read: what a customer gets at the end of an engagement's first week,
|
|
20
19
|
* before anything changes. Where the writeup says what letter each section
|
|
21
20
|
* earned, the read says why, and what changes. Its shape is the practice's
|
|
22
21
|
* FOOTPRINT.md, under What the Owner Gets: a title and a dated lede; the gist
|
|
23
|
-
* table (every section, its grade, and one line)
|
|
24
|
-
*
|
|
22
|
+
* table (every section, its grade, and one line), with a note under it for
|
|
23
|
+
* what the read leaves out; then the tiers; then the plan, in order; then one
|
|
24
|
+
* closing line and the sign off.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* what
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
26
|
+
* A section is the writeup's section card, the same `Topic`, so the two
|
|
27
|
+
* documents read alike (Forni, 2026-10-16): the trade name linked to its
|
|
28
|
+
* definition, the one liner, the grade, and the why line, which here is the
|
|
29
|
+
* summary of what we saw in one or two sentences; then the writeup's blocks
|
|
30
|
+
* (findings with the owner's screens beside them, the basics, a table, a
|
|
31
|
+
* fold); then what is the read's own, today and after as a two column table
|
|
32
|
+
* and what we recommend as a checklist; and the tools we looked through at
|
|
33
|
+
* the foot, as the sources. A beat with nothing in it is left out.
|
|
34
34
|
*
|
|
35
|
-
* It
|
|
36
|
-
*
|
|
37
|
-
* (the `.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* `
|
|
42
|
-
*
|
|
43
|
-
* the sign off person's `role`, which takes a node and is equally happy with
|
|
44
|
-
* a string.
|
|
35
|
+
* It wears the writeup's column, head, grade chip and key, tier heading,
|
|
36
|
+
* section card, and close (the `.op` classes), and adds only what is its own
|
|
37
|
+
* (the `.rd` classes). The gist table is drawn from the tiers, so a tier
|
|
38
|
+
* added later (Conversion, once the analytics access lands) is one more entry
|
|
39
|
+
* in `tiers` and appears in the table and the body alike; until then `note`
|
|
40
|
+
* says so under the table. Every prop but a finding's body and the sign off
|
|
41
|
+
* person's `role` is plain data, and both are equally happy with a string, so
|
|
42
|
+
* a routine can write a read as JSON.
|
|
45
43
|
*/
|
|
46
44
|
|
|
47
45
|
/** A link inside running text: the words the reader clicks and where they go. */
|
|
@@ -50,65 +48,36 @@ export type ReadLink = ArtifactLink;
|
|
|
50
48
|
/** A paragraph: a plain string, or runs of text and links in order. */
|
|
51
49
|
export type ReadParagraph = ArtifactParagraph;
|
|
52
50
|
|
|
53
|
-
/** A tool we looked through: its name, where it opens (in a new tab), and a line on what we read there. */
|
|
54
|
-
export interface ReadTool {
|
|
55
|
-
label: string;
|
|
56
|
-
href?: string;
|
|
57
|
-
/** One short line, e.g. "Page indexing, the last 90 days". */
|
|
58
|
-
note?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** The 1.0.0 name for a tool; the same shape. */
|
|
62
|
-
export type ReadSource = ReadTool;
|
|
63
|
-
|
|
64
|
-
/** One row of a section's facts: a short label and its value, e.g. "Pages on Google" and "9 of 9". */
|
|
65
|
-
export interface ReadFact {
|
|
66
|
-
label: string;
|
|
67
|
-
value: ReadParagraph;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** A list of examples under its own short heading; each item a paragraph that can carry links. */
|
|
71
|
-
export interface ReadExamples {
|
|
72
|
-
title?: string;
|
|
73
|
-
items: ReadParagraph[];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
51
|
/** One row of today and after: what a thing is now, and what it becomes. */
|
|
77
52
|
export interface ReadChange {
|
|
78
53
|
now: ReadParagraph;
|
|
79
54
|
after: ReadParagraph;
|
|
80
55
|
}
|
|
81
56
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
/** The question the section answers, in the owner's words, e.g. "Can Google read the site?", set under the heading. */
|
|
89
|
-
question: string;
|
|
90
|
-
/** The baseline's grade. Omit for a section not yet graded; the table shows a quiet dash. */
|
|
91
|
-
grade?: WriteupGrade;
|
|
57
|
+
/**
|
|
58
|
+
* One section of a tier: the writeup's section card (its `title`, `href`,
|
|
59
|
+
* `oneLiner`, `grade`, `why`, `blocks`, and `sources`), plus the read's own:
|
|
60
|
+
* the gist table's line, today and after, and what we recommend.
|
|
61
|
+
*/
|
|
62
|
+
export interface ReadSection extends Omit<WriteupTopic, "kind" | "why"> {
|
|
92
63
|
/** The one line in the gist table. */
|
|
93
64
|
gist: ReadParagraph;
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
sources?: ReadTool[];
|
|
98
|
-
/** What it showed: one or two short paragraphs. */
|
|
99
|
-
found: ReadParagraph[];
|
|
100
|
-
/** What it showed, as a compact two column table of facts under the paragraphs. */
|
|
101
|
-
facts?: ReadFact[];
|
|
102
|
-
/** What it showed, as lists of examples, each under its own short heading. */
|
|
103
|
-
examples?: ReadExamples[];
|
|
104
|
-
/** Proof pictures, each over its caption, e.g. a search result capture per market. */
|
|
105
|
-
shots?: WriteupShot[];
|
|
106
|
-
/** Why, in one sentence, set apart. */
|
|
107
|
-
why: ReadParagraph;
|
|
108
|
-
/** Today and after: what each thing is, and what it becomes. */
|
|
65
|
+
/** The summary of what we saw, in one or two sentences: the why line. A paragraph, so a read written as JSON can carry links in it. */
|
|
66
|
+
why?: ReactNode | ReadParagraph;
|
|
67
|
+
/** Today and after: what each thing is, and what it becomes. Set after the blocks. */
|
|
109
68
|
changes?: ReadChange[];
|
|
110
|
-
/**
|
|
111
|
-
recommend?: ReadParagraph[];
|
|
69
|
+
/** The recommendations, after today and after: a paragraph each, or one with its own icon and tone. */
|
|
70
|
+
recommend?: (ReadParagraph | ReadRecommendation)[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One recommendation with a bullet of its own: the icon it leads with, and the tone it reads in. Both fall back to the list's. */
|
|
74
|
+
export interface ReadRecommendation {
|
|
75
|
+
/** The recommendation: a paragraph, or a node when it carries a table or a list. */
|
|
76
|
+
body: ReactNode | ReadParagraph;
|
|
77
|
+
/** The icon as the bullet, e.g. a lucide icon at size 16; the accent's check when left out. */
|
|
78
|
+
icon?: ReactNode;
|
|
79
|
+
/** How the item reads; the accent when left out. */
|
|
80
|
+
tone?: Tone;
|
|
112
81
|
}
|
|
113
82
|
|
|
114
83
|
/** A tier, e.g. Health: its heading, the question it answers, and its sections in order. */
|
|
@@ -117,7 +86,9 @@ export interface ReadTier {
|
|
|
117
86
|
id?: string;
|
|
118
87
|
/** The tier's name, e.g. "Health". */
|
|
119
88
|
title: string;
|
|
120
|
-
/**
|
|
89
|
+
/** Where the name is defined, e.g. the practice's glossary; the heading links there when set. */
|
|
90
|
+
href?: string;
|
|
91
|
+
/** The questions the tier answers, in the owner's words. */
|
|
121
92
|
lead?: string;
|
|
122
93
|
sections: ReadSection[];
|
|
123
94
|
}
|
|
@@ -128,9 +99,11 @@ export interface ReadStep {
|
|
|
128
99
|
body?: ReadParagraph;
|
|
129
100
|
/** Whose step it is when it is not the practice's, e.g. "Yours" or a name; set beside the title. */
|
|
130
101
|
who?: string;
|
|
102
|
+
/** The cards the step draws on, linked in a line beneath it, e.g. { text: "Local SEO", href: "#local-seo" }. */
|
|
103
|
+
cards?: ReadLink[];
|
|
131
104
|
}
|
|
132
105
|
|
|
133
|
-
/** The plan: the steps in order, numbered. */
|
|
106
|
+
/** The plan: the steps in order, numbered, between the gist and the cards, so the whole program reads before any one card's detail. */
|
|
134
107
|
export interface ReadPlan {
|
|
135
108
|
/** The anchor a header stop points at, e.g. "plan". */
|
|
136
109
|
id?: string;
|
|
@@ -153,42 +126,36 @@ export interface ReadClose {
|
|
|
153
126
|
export interface ReadLabels {
|
|
154
127
|
/** The dateline's words before the business, e.g. "Prepared for". */
|
|
155
128
|
preparedFor: string;
|
|
156
|
-
/**
|
|
157
|
-
section: string;
|
|
158
|
-
/** The gist table's second column. */
|
|
159
|
-
grade: string;
|
|
160
|
-
/** The gist table's third column. */
|
|
129
|
+
/** What a screen reader calls the gist card when it has no title. */
|
|
161
130
|
gist: string;
|
|
131
|
+
/** What a screen reader hears before a letter, on the gist card and each section's card. */
|
|
132
|
+
grade: string;
|
|
162
133
|
/** What a screen reader hears for a section with no grade. */
|
|
163
134
|
notGraded: string;
|
|
164
|
-
/** The beat at the top of a section: the tools we looked through. */
|
|
165
|
-
tools: string;
|
|
166
|
-
/** The beat that says what the tools showed. */
|
|
167
|
-
found: string;
|
|
168
|
-
/** The one sentence beat. */
|
|
169
|
-
why: string;
|
|
170
135
|
/** The today and after table's first column. */
|
|
171
136
|
now: string;
|
|
172
137
|
/** The today and after table's second column. */
|
|
173
138
|
after: string;
|
|
174
139
|
/** The checklist beat at the foot of a section. */
|
|
175
140
|
recommend: string;
|
|
141
|
+
/** The word before the grade on a section's why line. */
|
|
142
|
+
why: string;
|
|
143
|
+
/** The label over a section's tools when it names none. */
|
|
144
|
+
sources: string;
|
|
176
145
|
/** The plan's heading when it names none. */
|
|
177
146
|
plan: string;
|
|
178
147
|
}
|
|
179
148
|
|
|
180
149
|
export const readLabels: ReadLabels = {
|
|
181
150
|
preparedFor: "Prepared for",
|
|
182
|
-
section: "Section",
|
|
183
|
-
grade: "Grade",
|
|
184
151
|
gist: "The Gist",
|
|
152
|
+
grade: "Grade",
|
|
185
153
|
notGraded: "Not graded yet",
|
|
186
|
-
tools: "Where We Looked",
|
|
187
|
-
found: "What It Showed",
|
|
188
|
-
why: "Why",
|
|
189
154
|
now: "Today",
|
|
190
155
|
after: "After",
|
|
191
|
-
recommend: "
|
|
156
|
+
recommend: "Recommendations",
|
|
157
|
+
why: "Why",
|
|
158
|
+
sources: "See it yourself",
|
|
192
159
|
plan: "The Plan",
|
|
193
160
|
};
|
|
194
161
|
|
|
@@ -200,9 +167,13 @@ export interface ReadProps {
|
|
|
200
167
|
date: string;
|
|
201
168
|
/** Two or three sentences under the title. */
|
|
202
169
|
lede: ReadParagraph;
|
|
203
|
-
/** The heading over the
|
|
170
|
+
/** The heading over the scorecard, e.g. "The Scorecard". */
|
|
204
171
|
gistTitle?: string;
|
|
205
|
-
/**
|
|
172
|
+
/** The line under the scorecard's heading, e.g. what it is. */
|
|
173
|
+
gistLead?: string;
|
|
174
|
+
/** The anchor a header stop points at for the scorecard, e.g. "scorecard". */
|
|
175
|
+
gistId?: string;
|
|
176
|
+
/** A callout over the scorecard, under its heading, e.g. what the read leaves out and when it follows. */
|
|
206
177
|
note?: ReadParagraph;
|
|
207
178
|
/** The key: what each letter means, in a few words each. */
|
|
208
179
|
gradeKey?: Array<{ grade: WriteupGrade; label: string }>;
|
|
@@ -218,51 +189,29 @@ function filled(paragraph: ReadParagraph | undefined | null): paragraph is ReadP
|
|
|
218
189
|
return paragraph !== undefined && paragraph !== null && !isEmpty(paragraph);
|
|
219
190
|
}
|
|
220
191
|
|
|
221
|
-
|
|
192
|
+
/** A string or runs of text and links, the shape JSON content arrives in; a node of elements is anything else. */
|
|
193
|
+
function isRuns(value: unknown): value is ReadParagraph {
|
|
194
|
+
if (typeof value === "string") return true;
|
|
222
195
|
return (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
{tool.href ? (
|
|
230
|
-
<a href={tool.href} {...newTabProps(tool.href, true)}>
|
|
231
|
-
{tool.label}
|
|
232
|
-
<ArrowUpRight size={13} aria-hidden="true" />
|
|
233
|
-
</a>
|
|
234
|
-
) : (
|
|
235
|
-
<span className="rd-tool-name">{tool.label}</span>
|
|
236
|
-
)}
|
|
237
|
-
{tool.note && <span className="rd-tool-note">{tool.note}</span>}
|
|
238
|
-
</li>
|
|
239
|
-
))}
|
|
240
|
-
</ul>
|
|
241
|
-
</div>
|
|
196
|
+
Array.isArray(value) &&
|
|
197
|
+
value.every(
|
|
198
|
+
(run) =>
|
|
199
|
+
typeof run === "string" ||
|
|
200
|
+
(typeof run === "object" && run !== null && "text" in run && "href" in run),
|
|
201
|
+
)
|
|
242
202
|
);
|
|
243
203
|
}
|
|
244
204
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: facts are static content in a fixed order
|
|
251
|
-
<tr key={i}>
|
|
252
|
-
<th scope="row">{fact.label}</th>
|
|
253
|
-
<td>
|
|
254
|
-
<Runs paragraph={fact.value} />
|
|
255
|
-
</td>
|
|
256
|
-
</tr>
|
|
257
|
-
))}
|
|
258
|
-
</tbody>
|
|
259
|
-
</table>
|
|
260
|
-
);
|
|
205
|
+
/** Content as a node: runs are rendered, a blank is dropped, and a node passes through. */
|
|
206
|
+
function asNode(value: ReactNode | ReadParagraph | undefined | null): ReactNode {
|
|
207
|
+
if (value === undefined || value === null) return undefined;
|
|
208
|
+
if (isRuns(value)) return filled(value) ? <Runs paragraph={value} /> : undefined;
|
|
209
|
+
return value;
|
|
261
210
|
}
|
|
262
211
|
|
|
263
212
|
function Changes({ changes, labels }: { changes: ReadChange[]; labels: ReadLabels }) {
|
|
264
213
|
return (
|
|
265
|
-
<div className="rd-beat rd-changes">
|
|
214
|
+
<div className="op-block rd-beat rd-changes">
|
|
266
215
|
<table className="rd-changes-table">
|
|
267
216
|
<thead>
|
|
268
217
|
<tr>
|
|
@@ -292,180 +241,117 @@ function Changes({ changes, labels }: { changes: ReadChange[]; labels: ReadLabel
|
|
|
292
241
|
);
|
|
293
242
|
}
|
|
294
243
|
|
|
295
|
-
|
|
244
|
+
/** A recommendation as its full shape, whichever way the content wrote it. */
|
|
245
|
+
function asRecommendation(item: ReadParagraph | ReadRecommendation): ReadRecommendation {
|
|
246
|
+
return isRuns(item) ? { body: item } : item;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function Recommend({ items, label }: { items: ReadRecommendation[]; label: string }) {
|
|
296
250
|
return (
|
|
297
|
-
<div className="rd-beat rd-recommend">
|
|
251
|
+
<div className="op-block rd-beat rd-recommend">
|
|
298
252
|
<h4>{label}</h4>
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
))}
|
|
309
|
-
</ul>
|
|
253
|
+
<IconList
|
|
254
|
+
className="rd-checks"
|
|
255
|
+
icon={<Check size={16} strokeWidth={2.5} />}
|
|
256
|
+
items={items.map((item) => ({
|
|
257
|
+
icon: item.icon,
|
|
258
|
+
tone: item.tone,
|
|
259
|
+
body: asNode(item.body),
|
|
260
|
+
}))}
|
|
261
|
+
/>
|
|
310
262
|
</div>
|
|
311
263
|
);
|
|
312
264
|
}
|
|
313
265
|
|
|
314
266
|
function Gist({
|
|
315
267
|
tiers,
|
|
268
|
+
id,
|
|
316
269
|
title,
|
|
270
|
+
lead,
|
|
271
|
+
note,
|
|
317
272
|
gradeKey,
|
|
318
273
|
labels,
|
|
319
274
|
}: {
|
|
320
275
|
tiers: ReadTier[];
|
|
276
|
+
id?: string;
|
|
321
277
|
title?: string;
|
|
278
|
+
lead?: string;
|
|
279
|
+
note?: ReadProps["note"];
|
|
322
280
|
gradeKey?: ReadProps["gradeKey"];
|
|
323
281
|
labels: ReadLabels;
|
|
324
282
|
}) {
|
|
325
|
-
const key = gradeKey ?? [];
|
|
326
283
|
return (
|
|
327
|
-
|
|
328
|
-
{
|
|
329
|
-
<div className="op-
|
|
330
|
-
|
|
331
|
-
{
|
|
332
|
-
<ul className="op-legend">
|
|
333
|
-
{key.map((entry) => (
|
|
334
|
-
<li key={entry.grade}>
|
|
335
|
-
<GradeChip grade={entry.grade} />
|
|
336
|
-
<span>{entry.label}</span>
|
|
337
|
-
</li>
|
|
338
|
-
))}
|
|
339
|
-
</ul>
|
|
340
|
-
)}
|
|
284
|
+
<>
|
|
285
|
+
{title && (
|
|
286
|
+
<div className="op-part" id={id}>
|
|
287
|
+
<h2>{title}</h2>
|
|
288
|
+
{lead && <p>{lead}</p>}
|
|
341
289
|
</div>
|
|
342
290
|
)}
|
|
343
|
-
|
|
344
|
-
<
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
{tier.sections.map((section) => (
|
|
366
|
-
<tr key={section.id}>
|
|
367
|
-
<th scope="row" className="rd-gist-section">
|
|
368
|
-
<a href={`#${section.id}`}>{section.name}</a>
|
|
369
|
-
</th>
|
|
370
|
-
<td className="rd-gist-grade">
|
|
371
|
-
{section.grade ? (
|
|
372
|
-
<>
|
|
373
|
-
<span className="op-sr-only">{gradeLabel[section.grade]}</span>
|
|
374
|
-
<span aria-hidden="true">
|
|
375
|
-
<GradeChip grade={section.grade} />
|
|
376
|
-
</span>
|
|
377
|
-
</>
|
|
378
|
-
) : (
|
|
379
|
-
<>
|
|
380
|
-
<span className="op-sr-only">{labels.notGraded}</span>
|
|
381
|
-
<span className="op-dash" aria-hidden="true" />
|
|
382
|
-
</>
|
|
383
|
-
)}
|
|
384
|
-
</td>
|
|
385
|
-
<td className="rd-gist-read">
|
|
386
|
-
<Runs paragraph={section.gist} />
|
|
387
|
-
</td>
|
|
388
|
-
</tr>
|
|
389
|
-
))}
|
|
390
|
-
</tbody>
|
|
391
|
-
))}
|
|
392
|
-
</table>
|
|
393
|
-
</div>
|
|
394
|
-
</section>
|
|
291
|
+
{filled(note) && (
|
|
292
|
+
<div className="rd-note" role="note">
|
|
293
|
+
<Paragraph paragraph={note} />
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
<ReportCard
|
|
297
|
+
className="rd-gist"
|
|
298
|
+
label={title ?? labels.gist}
|
|
299
|
+
legend={gradeKey}
|
|
300
|
+
legendAt="foot"
|
|
301
|
+
labels={{ grade: labels.grade, notGraded: labels.notGraded }}
|
|
302
|
+
groups={tiers.map((tier) => ({
|
|
303
|
+
title: tier.title,
|
|
304
|
+
rows: tier.sections.map((section) => ({
|
|
305
|
+
name: section.title,
|
|
306
|
+
href: `#${section.id}`,
|
|
307
|
+
grade: section.grade,
|
|
308
|
+
line: <Runs paragraph={section.gist} />,
|
|
309
|
+
})),
|
|
310
|
+
}))}
|
|
311
|
+
/>
|
|
312
|
+
</>
|
|
395
313
|
);
|
|
396
314
|
}
|
|
397
315
|
|
|
316
|
+
/** A section: the writeup's card, with today and after and the checklist handed in after its blocks. */
|
|
398
317
|
function Section({ section, labels }: { section: ReadSection; labels: ReadLabels }) {
|
|
399
|
-
const tools = (section.tools ?? section.sources ?? []).filter((tool) => tool?.label?.trim());
|
|
400
|
-
const found = (section.found ?? []).filter(filled);
|
|
401
|
-
const facts = (section.facts ?? []).filter((fact) => filled(fact?.value));
|
|
402
|
-
const examples = (section.examples ?? [])
|
|
403
|
-
.map((list) => ({ title: list?.title, items: (list?.items ?? []).filter(filled) }))
|
|
404
|
-
.filter((list) => list.items.length > 0);
|
|
405
|
-
const shots = section.shots ?? [];
|
|
406
318
|
const changes = (section.changes ?? []).filter(
|
|
407
319
|
(change) => filled(change?.now) || filled(change?.after),
|
|
408
320
|
);
|
|
409
|
-
const recommend = (section.recommend ?? [])
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
<GradeChip grade={section.grade} />
|
|
423
|
-
</span>
|
|
424
|
-
</>
|
|
425
|
-
)}
|
|
426
|
-
</header>
|
|
427
|
-
{tools.length > 0 && <Tools tools={tools} label={labels.tools} />}
|
|
428
|
-
{showed && (
|
|
429
|
-
<div className="rd-beat rd-found">
|
|
430
|
-
<h4>{labels.found}</h4>
|
|
431
|
-
{found.map((paragraph, i) => (
|
|
432
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: paragraphs are static content in a fixed order
|
|
433
|
-
<Paragraph paragraph={paragraph} key={i} />
|
|
434
|
-
))}
|
|
435
|
-
{facts.length > 0 && <Facts facts={facts} />}
|
|
436
|
-
{examples.map((list, i) => (
|
|
437
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: lists are static content in a fixed order, and a title may repeat
|
|
438
|
-
<div className="rd-examples-block" key={i}>
|
|
439
|
-
{list.title && <h5>{list.title}</h5>}
|
|
440
|
-
<ul className="rd-examples">
|
|
441
|
-
{list.items.map((example, j) => (
|
|
442
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: examples are static content in a fixed order
|
|
443
|
-
<li key={j}>
|
|
444
|
-
<Runs paragraph={example} />
|
|
445
|
-
</li>
|
|
446
|
-
))}
|
|
447
|
-
</ul>
|
|
448
|
-
</div>
|
|
449
|
-
))}
|
|
450
|
-
{shots.length > 0 && (
|
|
451
|
-
<div className="rd-shots" data-count={Math.min(shots.length, 3)}>
|
|
452
|
-
{shots.map((shot) => (
|
|
453
|
-
<Shot shot={shot} key={shot.src} />
|
|
454
|
-
))}
|
|
455
|
-
</div>
|
|
456
|
-
)}
|
|
457
|
-
</div>
|
|
458
|
-
)}
|
|
459
|
-
{filled(section.why) && (
|
|
460
|
-
<div className="rd-beat rd-why">
|
|
461
|
-
<h4>{labels.why}</h4>
|
|
462
|
-
<Paragraph paragraph={section.why} />
|
|
463
|
-
</div>
|
|
464
|
-
)}
|
|
465
|
-
{changes.length > 0 && <Changes changes={changes} labels={labels} />}
|
|
466
|
-
{recommend.length > 0 && <Recommend items={recommend} label={labels.recommend} />}
|
|
467
|
-
</section>
|
|
321
|
+
const recommend = (section.recommend ?? [])
|
|
322
|
+
.filter((item) => item !== undefined && item !== null)
|
|
323
|
+
.map(asRecommendation)
|
|
324
|
+
.filter((item) => (isRuns(item.body) ? filled(item.body) : item.body != null));
|
|
325
|
+
const blocks = (section.blocks ?? []).map((block) =>
|
|
326
|
+
block.kind === "findings"
|
|
327
|
+
? {
|
|
328
|
+
...block,
|
|
329
|
+
items: block.items
|
|
330
|
+
.filter((item) => item?.lead?.trim())
|
|
331
|
+
.map((item) => ({ ...item, body: asNode(item.body) })),
|
|
332
|
+
}
|
|
333
|
+
: block,
|
|
468
334
|
);
|
|
335
|
+
const sources = (section.sources ?? []).filter((source) => source?.label?.trim());
|
|
336
|
+
const topic: WriteupTopic = {
|
|
337
|
+
kind: "topic",
|
|
338
|
+
...section,
|
|
339
|
+
why: asNode(section.why),
|
|
340
|
+
whyLabel: section.whyLabel ?? labels.why,
|
|
341
|
+
gradeWord: section.gradeWord ?? labels.grade,
|
|
342
|
+
blocks,
|
|
343
|
+
sources,
|
|
344
|
+
sourcesLabel: section.sourcesLabel ?? labels.sources,
|
|
345
|
+
noShot: section.noShot ?? true,
|
|
346
|
+
};
|
|
347
|
+
const extra =
|
|
348
|
+
changes.length + recommend.length > 0 ? (
|
|
349
|
+
<>
|
|
350
|
+
{changes.length > 0 && <Changes changes={changes} labels={labels} />}
|
|
351
|
+
{recommend.length > 0 && <Recommend items={recommend} label={labels.recommend} />}
|
|
352
|
+
</>
|
|
353
|
+
) : undefined;
|
|
354
|
+
return <Topic topic={topic} extra={extra} />;
|
|
469
355
|
}
|
|
470
356
|
|
|
471
357
|
function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
|
|
@@ -476,21 +362,24 @@ function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
|
|
|
476
362
|
<ol className="rd-steps">
|
|
477
363
|
{plan.steps.map((step) => (
|
|
478
364
|
<li className="rd-step" key={step.title}>
|
|
479
|
-
<
|
|
480
|
-
<
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
365
|
+
<div className="rd-step-body">
|
|
366
|
+
<p className="rd-step-head">
|
|
367
|
+
<b className="rd-step-title">{step.title}</b>
|
|
368
|
+
{step.who && <span className="rd-step-who">{step.who}</span>}
|
|
369
|
+
</p>
|
|
370
|
+
{filled(step.body) && <Paragraph paragraph={step.body} />}
|
|
371
|
+
{(step.cards ?? []).some((card) => card?.href) && (
|
|
372
|
+
<p className="rd-step-cards">
|
|
373
|
+
{(step.cards ?? [])
|
|
374
|
+
.filter((card) => card?.href)
|
|
375
|
+
.map((card, i) => (
|
|
376
|
+
<a href={card.href} key={`${i}-${card.href}`}>
|
|
377
|
+
{card.text}
|
|
378
|
+
</a>
|
|
379
|
+
))}
|
|
380
|
+
</p>
|
|
492
381
|
)}
|
|
493
|
-
</
|
|
382
|
+
</div>
|
|
494
383
|
</li>
|
|
495
384
|
))}
|
|
496
385
|
</ol>
|
|
@@ -504,6 +393,8 @@ export function Read({
|
|
|
504
393
|
date,
|
|
505
394
|
lede,
|
|
506
395
|
gistTitle,
|
|
396
|
+
gistLead,
|
|
397
|
+
gistId,
|
|
507
398
|
note,
|
|
508
399
|
gradeKey,
|
|
509
400
|
tiers,
|
|
@@ -528,19 +419,30 @@ export function Read({
|
|
|
528
419
|
{filled(lede) && <Paragraph paragraph={lede} className="op-intro rd-lede" />}
|
|
529
420
|
</header>
|
|
530
421
|
{shownTiers.length > 0 && (
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
{
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
422
|
+
<Gist
|
|
423
|
+
tiers={shownTiers}
|
|
424
|
+
id={gistId}
|
|
425
|
+
title={gistTitle}
|
|
426
|
+
lead={gistLead}
|
|
427
|
+
note={note}
|
|
428
|
+
gradeKey={gradeKey}
|
|
429
|
+
labels={labels}
|
|
430
|
+
/>
|
|
539
431
|
)}
|
|
432
|
+
{plan.steps.length > 0 && <Plan plan={plan} labels={labels} />}
|
|
540
433
|
{shownTiers.map((tier) => (
|
|
541
434
|
<div className="rd-tier" key={tier.title}>
|
|
542
435
|
<div className="op-part" id={tier.id}>
|
|
543
|
-
<h2>
|
|
436
|
+
<h2>
|
|
437
|
+
{tier.href ? (
|
|
438
|
+
<a href={tier.href} {...newTabProps(tier.href, true)}>
|
|
439
|
+
{tier.title}
|
|
440
|
+
<ArrowUpRight size={14} aria-hidden="true" />
|
|
441
|
+
</a>
|
|
442
|
+
) : (
|
|
443
|
+
tier.title
|
|
444
|
+
)}
|
|
445
|
+
</h2>
|
|
544
446
|
{tier.lead && <p>{tier.lead}</p>}
|
|
545
447
|
</div>
|
|
546
448
|
{tier.sections.map((section) => (
|
|
@@ -548,7 +450,6 @@ export function Read({
|
|
|
548
450
|
))}
|
|
549
451
|
</div>
|
|
550
452
|
))}
|
|
551
|
-
{plan.steps.length > 0 && <Plan plan={plan} labels={labels} />}
|
|
552
453
|
{filled(close.line) && <Paragraph paragraph={close.line} className="rd-closing" />}
|
|
553
454
|
{close.signOff && close.person && (
|
|
554
455
|
<footer className="op-close rd-close">
|