sanity-plugin-sgntech-interview 1.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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Framework-side entry point: the React renderer for interviews. This entry
3
+ * imports neither `sanity` nor `@sanity/ui`, so it can be used in a Next.js,
4
+ * Remix or Astro app without pulling the Studio into the bundle.
5
+ *
6
+ * ```ts
7
+ * import {Interview, ensureInterviewStyles} from 'sanity-plugin-sgntech-interview/render'
8
+ * ```
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import { JSX } from "react";
14
+ import type { ReactNode } from "react";
15
+
16
+ /**
17
+ * Any object with an `_type` that is a string. Can hold any other properties.
18
+ * @public
19
+ */
20
+ declare type ArbitraryTypedObject = TypedObject & {
21
+ [key: string]: any;
22
+ };
23
+
24
+ /**
25
+ * Who is asking: the block's own choice, otherwise the first speaker marked as
26
+ * an interviewer.
27
+ *
28
+ * @public
29
+ */
30
+ export declare function askingSpeaker(
31
+ speakers: Speaker[] | undefined,
32
+ value: InterviewValue | undefined,
33
+ ): Speaker | undefined;
34
+
35
+ /**
36
+ * Same as {@link interviewPortableTextComponents}, but for a custom type name
37
+ * or with fixed options — the speaker list and the recording URL, typically.
38
+ *
39
+ * @public
40
+ */
41
+ export declare function createInterviewPortableTextComponents(
42
+ options?: RenderOptions & {
43
+ typeName?: string;
44
+ answer?: (value: InterviewValue) => React.ReactNode;
45
+ },
46
+ ): {
47
+ types: Record<
48
+ string,
49
+ (props: { value: InterviewValue }) => React.JSX.Element | null
50
+ >;
51
+ };
52
+
53
+ /**
54
+ * Adds {@link interviewStyles} to the document once.
55
+ *
56
+ * Only needed when the stylesheet is not already part of the site's own CSS.
57
+ *
58
+ * @public
59
+ */
60
+ export declare function ensureInterviewStyles(): void;
61
+
62
+ /**
63
+ * The speaker a block refers to, or the first interviewer when it asks for one
64
+ * without naming it.
65
+ *
66
+ * @public
67
+ */
68
+ export declare function findSpeaker(
69
+ speakers: Speaker[] | undefined,
70
+ id: string | undefined,
71
+ ): Speaker | undefined;
72
+
73
+ /**
74
+ * One question-and-answer pair.
75
+ *
76
+ * The answer's rich text is passed in as children, so the site keeps using its
77
+ * own Portable Text setup rather than a second one bundled here.
78
+ *
79
+ * @public
80
+ */
81
+ export declare function Interview(props: InterviewProps): React.JSX.Element;
82
+
83
+ /**
84
+ * Portable Text block renderer for the `interviewBlock` object type.
85
+ *
86
+ * The answer is rendered with `@portabletext/react` unless the site passes its
87
+ * own renderer through `answer`.
88
+ *
89
+ * @public
90
+ */
91
+ export declare function InterviewBlock(props: {
92
+ value: InterviewValue;
93
+ options?: RenderOptions & {
94
+ answer?: (value: InterviewValue) => React.ReactNode;
95
+ };
96
+ }): React.JSX.Element | null;
97
+
98
+ /**
99
+ * Words the renderer prints, so a site can translate them.
100
+ *
101
+ * @public
102
+ */
103
+ export declare interface InterviewLabels {
104
+ translated: string;
105
+ originalWording: string;
106
+ }
107
+
108
+ /**
109
+ * Drop-in `components` fragment for `@portabletext/react`.
110
+ *
111
+ * @public
112
+ */
113
+ export declare const interviewPortableTextComponents: {
114
+ types: {
115
+ interviewBlock: ({ value }: { value: InterviewValue }) => JSX.Element;
116
+ };
117
+ };
118
+
119
+ /**
120
+ * Props for {@link Interview}.
121
+ *
122
+ * @public
123
+ */
124
+ export declare interface InterviewProps {
125
+ value: InterviewValue;
126
+ /** The document's speaker list. */
127
+ speakers?: Speaker[];
128
+ /** Recording the timestamp points into; without it the time is plain text. */
129
+ source?: string;
130
+ labels?: Partial<InterviewLabels>;
131
+ /** Renders the answer's Portable Text. */
132
+ children?: ReactNode;
133
+ className?: string;
134
+ }
135
+
136
+ /**
137
+ * Structural CSS for an interview: the speaker column and the turns.
138
+ *
139
+ * No colours, no fonts. Every value worth changing is a custom property with
140
+ * its default at the point of use — a default declared on the block itself
141
+ * would beat the same variable set on an ancestor, so theming from a wrapper
142
+ * would silently do nothing.
143
+ *
144
+ * Set `--sgn-interview-layout: inline` on any ancestor to get the classic
145
+ * `NAME: text` form instead of two columns.
146
+ *
147
+ * @public
148
+ */
149
+ export declare const interviewStyles =
150
+ "\n.sgn-interview {\n margin: 0;\n color: inherit;\n font: inherit;\n}\n\n.sgn-interview__turn {\n display: grid;\n grid-template-columns: var(--sgn-interview-speaker-width, 7.5rem) minmax(0, 1fr);\n gap: var(--sgn-interview-column-gap, 1.25rem);\n align-items: baseline;\n}\n\n.sgn-interview__turn + .sgn-interview__turn {\n margin-top: var(--sgn-interview-turn-gap, 0.75rem);\n}\n\n.sgn-interview__turn--answer + .sgn-interview__turn--question {\n margin-top: var(--sgn-interview-pair-gap, 1.75rem);\n}\n\n.sgn-interview__speaker {\n margin: 0;\n font-size: var(--sgn-interview-speaker-size, 0.8125em);\n font-weight: var(--sgn-interview-speaker-weight, 600);\n letter-spacing: var(--sgn-interview-speaker-tracking, 0.06em);\n text-transform: var(--sgn-interview-speaker-transform, uppercase);\n opacity: var(--sgn-interview-speaker-opacity, 0.72);\n overflow-wrap: break-word;\n}\n\n.sgn-interview__role {\n display: block;\n font-weight: 400;\n text-transform: none;\n letter-spacing: normal;\n opacity: 0.75;\n}\n\n.sgn-interview__text {\n margin: 0;\n}\n\n.sgn-interview__text > p {\n margin: 0;\n}\n\n.sgn-interview__text > p + p {\n margin-top: var(--sgn-interview-paragraph-gap, 0.75em);\n}\n\n.sgn-interview__turn--question .sgn-interview__text {\n font-weight: var(--sgn-interview-question-weight, 600);\n}\n\n.sgn-interview__meta {\n margin: var(--sgn-interview-meta-gap, 0.35rem) 0 0;\n font-size: var(--sgn-interview-meta-size, 0.8125em);\n opacity: var(--sgn-interview-meta-opacity, 0.65);\n}\n\n.sgn-interview__meta > * + *::before {\n content: ' \u00B7 ';\n}\n\n.sgn-interview__original {\n margin: var(--sgn-interview-meta-gap, 0.35rem) 0 0;\n font-size: var(--sgn-interview-meta-size, 0.8125em);\n font-style: italic;\n opacity: var(--sgn-interview-meta-opacity, 0.65);\n}\n\n/* One column once the speaker column would squeeze the text, and whenever the\n inline layout is asked for. */\n@container (max-width: 30rem) {\n .sgn-interview__turn {\n grid-template-columns: minmax(0, 1fr);\n gap: var(--sgn-interview-stacked-gap, 0.2rem);\n }\n}\n\n@media (max-width: 30rem) {\n .sgn-interview__turn {\n grid-template-columns: minmax(0, 1fr);\n gap: var(--sgn-interview-stacked-gap, 0.2rem);\n }\n}\n";
151
+
152
+ /**
153
+ * One question and its answer.
154
+ *
155
+ * @public
156
+ */
157
+ export declare interface InterviewValue {
158
+ _key?: string;
159
+ _type?: string;
160
+ question?: string;
161
+ answer?: PortableTextBlock[];
162
+ /** Who is answering; matches {@link Speaker.id}. */
163
+ speaker?: string;
164
+ /** Who is asking, when it is not the document's default interviewer. */
165
+ askedBy?: string;
166
+ /** `12:34` or `1:02:33`, pointing into the recording. */
167
+ timestamp?: string;
168
+ /** Set when the answer was translated rather than quoted verbatim. */
169
+ translated?: boolean;
170
+ /** The answer as it was actually said. */
171
+ originalAnswer?: string;
172
+ }
173
+
174
+ /**
175
+ * A Portable Text Block can be thought of as one paragraph, quote or list item.
176
+ * In other words, it is a container for text, that can have a visual style associated with it.
177
+ * The actual text value is stored in portable text spans inside of the `childen` array.
178
+ *
179
+ * @typeParam M - Mark types that be used for text spans
180
+ * @typeParam C - Types allowed as children of this block
181
+ * @typeParam S - Allowed block styles (eg `normal`, `blockquote`, `h3` etc)
182
+ * @typeParam L - Allowed list item types (eg `number`, `bullet` etc)
183
+ * @public
184
+ */
185
+ declare interface PortableTextBlock<
186
+ M extends PortableTextMarkDefinition = PortableTextMarkDefinition,
187
+ C extends TypedObject = ArbitraryTypedObject | PortableTextSpan,
188
+ S extends string = PortableTextBlockStyle,
189
+ L extends string = PortableTextListItemType,
190
+ > extends TypedObject {
191
+ /**
192
+ * Type name identifying this as a portable text block.
193
+ * All items within a portable text array should have a `_type` property.
194
+ *
195
+ * Usually 'block', but can be customized to other values
196
+ */
197
+ _type: "block" | (string & {});
198
+ /**
199
+ * A key that identifies this block uniquely within the parent array. Used to more easily address
200
+ * the block when editing collaboratively, but is also very useful for keys inside of React and
201
+ * other rendering frameworks that can use keys to optimize operations.
202
+ */
203
+ _key?: string;
204
+ /**
205
+ * Array of inline items for this block. Usually contain text spans, but can be
206
+ * configured to include inline objects of other types as well.
207
+ */
208
+ children: C[];
209
+ /**
210
+ * Array of mark definitions used in child text spans. By having them be on the block level,
211
+ * the same mark definition can be reused for multiple text spans, which is often the case
212
+ * with nested marks.
213
+ */
214
+ markDefs?: M[];
215
+ /**
216
+ * Visual style of the block
217
+ * Common values: 'normal', 'blockquote', 'h1'...'h6'
218
+ */
219
+ style?: S;
220
+ /**
221
+ * If this block is a list item, identifies which style of list item this is
222
+ * Common values: 'bullet', 'number', but can be configured
223
+ */
224
+ listItem?: L;
225
+ /**
226
+ * If this block is a list item, identifies which level of nesting it belongs within
227
+ */
228
+ level?: number;
229
+ }
230
+
231
+ /**
232
+ * A set of _common_ (but not required/standarized) block styles
233
+ * @public
234
+ */
235
+ declare type PortableTextBlockStyle =
236
+ | "normal"
237
+ | "blockquote"
238
+ | "h1"
239
+ | "h2"
240
+ | "h3"
241
+ | "h4"
242
+ | "h5"
243
+ | "h6"
244
+ | (string & {});
245
+
246
+ /**
247
+ * A set of _common_ (but not required/standardized) list item types
248
+ * @public
249
+ */
250
+ declare type PortableTextListItemType = "bullet" | "number" | (string & {});
251
+
252
+ /**
253
+ * A mark definition holds information for marked text. For instance, a text span could reference
254
+ * a mark definition for a hyperlink, a geoposition, a reference to a document or anything that is
255
+ * representable as a JSON object.
256
+ * @public
257
+ */
258
+ declare interface PortableTextMarkDefinition {
259
+ /**
260
+ * Unknown properties
261
+ */
262
+ [key: string]: unknown;
263
+ /**
264
+ * Identifies the type of mark this is, and is used to pick the correct React components to use
265
+ * when rendering a text span marked with this mark type.
266
+ */
267
+ _type: string;
268
+ /**
269
+ * Uniquely identifies this mark definition within the block
270
+ */
271
+ _key: string;
272
+ }
273
+
274
+ /**
275
+ * A Portable Text Span holds a chunk of the actual text value of a Portable Text Block
276
+ * @public
277
+ */
278
+ declare interface PortableTextSpan {
279
+ /**
280
+ * Type is always `span` for portable text spans, as these don't vary in shape
281
+ */
282
+ _type: "span";
283
+ /**
284
+ * Unique (within parent block) key for this portable text span
285
+ */
286
+ _key?: string;
287
+ /**
288
+ * The actual text value of this text span
289
+ */
290
+ text: string;
291
+ /**
292
+ * An array of marks this text span is annotated with, identified by its `_key`.
293
+ * If the key cannot be found in the parent blocks mark definition, the mark is assumed to be a
294
+ * decorator (a simpler mark without any properties - for instance `strong` or `em`)
295
+ */
296
+ marks?: string[];
297
+ }
298
+
299
+ declare type RenderOptions = Omit<InterviewProps, "value" | "children">;
300
+
301
+ /**
302
+ * Someone taking part in the interview, defined once on the document.
303
+ *
304
+ * @public
305
+ */
306
+ export declare interface Speaker {
307
+ _key?: string;
308
+ /** Short id the blocks refer to, e.g. `till`. */
309
+ id?: string;
310
+ name?: string;
311
+ /** Shown under the name, e.g. `Guitarist` or `Editor`. */
312
+ role?: string;
313
+ /** Marks who is asking, so a renderer can style questions differently. */
314
+ interviewer?: boolean;
315
+ /** Set when the speaker is a reference to a person document. */
316
+ person?: {
317
+ _ref?: string;
318
+ _type?: string;
319
+ };
320
+ }
321
+
322
+ /**
323
+ * What to print for a speaker, falling back through name, id and a dash so a
324
+ * half-filled interview still renders.
325
+ *
326
+ * @public
327
+ */
328
+ export declare function speakerLabel(
329
+ speaker: Speaker | undefined,
330
+ fallback?: string,
331
+ ): string;
332
+
333
+ /**
334
+ * Turns `12:34` or `1:02:33` into seconds, for links into a recording.
335
+ *
336
+ * Returns `undefined` for anything it cannot read, so a typo becomes a plain
337
+ * label rather than a link to second zero.
338
+ *
339
+ * @public
340
+ */
341
+ export declare function timestampSeconds(
342
+ input: string | undefined,
343
+ ): number | undefined;
344
+
345
+ /**
346
+ * Adds a start time to a media URL, in the form each service expects.
347
+ *
348
+ * @public
349
+ */
350
+ export declare function timestampUrl(
351
+ source: string | undefined,
352
+ seconds: number | undefined,
353
+ ): string | undefined;
354
+
355
+ /**
356
+ * Any object with an `_type` property (which is required in portable text arrays),
357
+ * as well as a _potential_ `_key` (highly encouraged)
358
+ * @public
359
+ */
360
+ declare interface TypedObject {
361
+ /**
362
+ * Identifies the type of object/span this is, and is used to pick the correct React components
363
+ * to use when rendering a span or inline object with this type.
364
+ */
365
+ _type: string;
366
+ /**
367
+ * Uniquely identifies this object within its parent block.
368
+ * Not _required_, but highly encouraged.
369
+ */
370
+ _key?: string;
371
+ }
372
+
373
+ /**
374
+ * Speakers that can actually be offered in a picker.
375
+ *
376
+ * @public
377
+ */
378
+ export declare function usableSpeakers(
379
+ speakers: Speaker[] | undefined,
380
+ ): Speaker[];
381
+
382
+ export {};
package/dist/render.js ADDED
@@ -0,0 +1,165 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import { findSpeaker, askingSpeaker, timestampSeconds, timestampUrl, speakerLabel } from "./_chunks-es/speakers.js";
3
+ import { usableSpeakers } from "./_chunks-es/speakers.js";
4
+ import { PortableText } from "@portabletext/react";
5
+ const DEFAULT_LABELS = {
6
+ translated: "Translated",
7
+ originalWording: "Originally"
8
+ };
9
+ function Turn(props) {
10
+ const { kind, speaker, children, meta } = props;
11
+ return /* @__PURE__ */ jsxs("div", { className: `sgn-interview__turn sgn-interview__turn--${kind}`, children: [
12
+ /* @__PURE__ */ jsxs("p", { className: "sgn-interview__speaker", children: [
13
+ speakerLabel(speaker),
14
+ speaker?.role && /* @__PURE__ */ jsx("span", { className: "sgn-interview__role", children: speaker.role })
15
+ ] }),
16
+ /* @__PURE__ */ jsxs("div", { className: "sgn-interview__text", children: [
17
+ children,
18
+ meta
19
+ ] })
20
+ ] });
21
+ }
22
+ function Interview(props) {
23
+ const { value, speakers, source, labels, children, className } = props, words = { ...DEFAULT_LABELS, ...labels }, answering = findSpeaker(speakers, value.speaker), asking = askingSpeaker(speakers, value), seconds = timestampSeconds(value.timestamp), link = timestampUrl(source, seconds), meta = value.timestamp || value.translated ? /* @__PURE__ */ jsxs("p", { className: "sgn-interview__meta", children: [
24
+ value.timestamp && /* @__PURE__ */ jsx("span", { children: link && seconds !== void 0 ? /* @__PURE__ */ jsx("a", { href: link, children: value.timestamp }) : value.timestamp }),
25
+ value.translated && /* @__PURE__ */ jsx("span", { children: words.translated })
26
+ ] }) : void 0;
27
+ return /* @__PURE__ */ jsxs("div", { className: ["sgn-interview", className].filter(Boolean).join(" "), children: [
28
+ value.question && /* @__PURE__ */ jsx(Turn, { kind: "question", speaker: asking, children: /* @__PURE__ */ jsx("p", { children: value.question }) }),
29
+ /* @__PURE__ */ jsxs(Turn, { kind: "answer", speaker: answering, meta, children: [
30
+ children,
31
+ value.translated && value.originalAnswer && /* @__PURE__ */ jsxs("p", { className: "sgn-interview__original", children: [
32
+ words.originalWording,
33
+ ": \u201C",
34
+ value.originalAnswer,
35
+ "\u201D"
36
+ ] })
37
+ ] })
38
+ ] });
39
+ }
40
+ function InterviewBlock(props) {
41
+ const { value, options } = props, { answer, ...rest } = options || {};
42
+ return /* @__PURE__ */ jsx(Interview, { value, ...rest, children: answer ? answer(value) : /* @__PURE__ */ jsx(PortableText, { value: value.answer ?? [] }) });
43
+ }
44
+ const interviewPortableTextComponents = {
45
+ types: {
46
+ interviewBlock: ({ value }) => /* @__PURE__ */ jsx(InterviewBlock, { value })
47
+ }
48
+ };
49
+ function createInterviewPortableTextComponents(options) {
50
+ const { typeName = "interviewBlock", ...rest } = options || {};
51
+ return {
52
+ types: {
53
+ [typeName]: ({ value }) => /* @__PURE__ */ jsx(InterviewBlock, { value, options: rest })
54
+ }
55
+ };
56
+ }
57
+ const interviewStyles = `
58
+ .sgn-interview {
59
+ margin: 0;
60
+ color: inherit;
61
+ font: inherit;
62
+ }
63
+
64
+ .sgn-interview__turn {
65
+ display: grid;
66
+ grid-template-columns: var(--sgn-interview-speaker-width, 7.5rem) minmax(0, 1fr);
67
+ gap: var(--sgn-interview-column-gap, 1.25rem);
68
+ align-items: baseline;
69
+ }
70
+
71
+ .sgn-interview__turn + .sgn-interview__turn {
72
+ margin-top: var(--sgn-interview-turn-gap, 0.75rem);
73
+ }
74
+
75
+ .sgn-interview__turn--answer + .sgn-interview__turn--question {
76
+ margin-top: var(--sgn-interview-pair-gap, 1.75rem);
77
+ }
78
+
79
+ .sgn-interview__speaker {
80
+ margin: 0;
81
+ font-size: var(--sgn-interview-speaker-size, 0.8125em);
82
+ font-weight: var(--sgn-interview-speaker-weight, 600);
83
+ letter-spacing: var(--sgn-interview-speaker-tracking, 0.06em);
84
+ text-transform: var(--sgn-interview-speaker-transform, uppercase);
85
+ opacity: var(--sgn-interview-speaker-opacity, 0.72);
86
+ overflow-wrap: break-word;
87
+ }
88
+
89
+ .sgn-interview__role {
90
+ display: block;
91
+ font-weight: 400;
92
+ text-transform: none;
93
+ letter-spacing: normal;
94
+ opacity: 0.75;
95
+ }
96
+
97
+ .sgn-interview__text {
98
+ margin: 0;
99
+ }
100
+
101
+ .sgn-interview__text > p {
102
+ margin: 0;
103
+ }
104
+
105
+ .sgn-interview__text > p + p {
106
+ margin-top: var(--sgn-interview-paragraph-gap, 0.75em);
107
+ }
108
+
109
+ .sgn-interview__turn--question .sgn-interview__text {
110
+ font-weight: var(--sgn-interview-question-weight, 600);
111
+ }
112
+
113
+ .sgn-interview__meta {
114
+ margin: var(--sgn-interview-meta-gap, 0.35rem) 0 0;
115
+ font-size: var(--sgn-interview-meta-size, 0.8125em);
116
+ opacity: var(--sgn-interview-meta-opacity, 0.65);
117
+ }
118
+
119
+ .sgn-interview__meta > * + *::before {
120
+ content: ' \xB7 ';
121
+ }
122
+
123
+ .sgn-interview__original {
124
+ margin: var(--sgn-interview-meta-gap, 0.35rem) 0 0;
125
+ font-size: var(--sgn-interview-meta-size, 0.8125em);
126
+ font-style: italic;
127
+ opacity: var(--sgn-interview-meta-opacity, 0.65);
128
+ }
129
+
130
+ /* One column once the speaker column would squeeze the text, and whenever the
131
+ inline layout is asked for. */
132
+ @container (max-width: 30rem) {
133
+ .sgn-interview__turn {
134
+ grid-template-columns: minmax(0, 1fr);
135
+ gap: var(--sgn-interview-stacked-gap, 0.2rem);
136
+ }
137
+ }
138
+
139
+ @media (max-width: 30rem) {
140
+ .sgn-interview__turn {
141
+ grid-template-columns: minmax(0, 1fr);
142
+ gap: var(--sgn-interview-stacked-gap, 0.2rem);
143
+ }
144
+ }
145
+ `, STYLE_ID = "sgn-interview-styles";
146
+ function ensureInterviewStyles() {
147
+ if (typeof document > "u" || document.getElementById(STYLE_ID)) return;
148
+ const element = document.createElement("style");
149
+ element.id = STYLE_ID, element.textContent = interviewStyles, document.head.appendChild(element);
150
+ }
151
+ export {
152
+ Interview,
153
+ InterviewBlock,
154
+ askingSpeaker,
155
+ createInterviewPortableTextComponents,
156
+ ensureInterviewStyles,
157
+ findSpeaker,
158
+ interviewPortableTextComponents,
159
+ interviewStyles,
160
+ speakerLabel,
161
+ timestampSeconds,
162
+ timestampUrl,
163
+ usableSpeakers
164
+ };
165
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sources":["../src/render/Interview.tsx","../src/render/portableText.tsx","../src/render/styles.ts"],"sourcesContent":["import type {ReactNode} from 'react'\n\nimport {askingSpeaker, findSpeaker, speakerLabel, timestampSeconds, timestampUrl} from '../lib/speakers'\nimport type {InterviewValue, Speaker} from '../lib/types'\n\n/**\n * Words the renderer prints, so a site can translate them.\n *\n * @public\n */\nexport interface InterviewLabels {\n translated: string\n originalWording: string\n}\n\nconst DEFAULT_LABELS: InterviewLabels = {\n translated: 'Translated',\n originalWording: 'Originally',\n}\n\n/**\n * Props for {@link Interview}.\n *\n * @public\n */\nexport interface InterviewProps {\n value: InterviewValue\n /** The document's speaker list. */\n speakers?: Speaker[]\n /** Recording the timestamp points into; without it the time is plain text. */\n source?: string\n labels?: Partial<InterviewLabels>\n /** Renders the answer's Portable Text. */\n children?: ReactNode\n className?: string\n}\n\nfunction Turn(props: {\n kind: 'question' | 'answer'\n speaker: Speaker | undefined\n children: ReactNode\n meta?: ReactNode\n}): React.JSX.Element {\n const {kind, speaker, children, meta} = props\n\n return (\n <div className={`sgn-interview__turn sgn-interview__turn--${kind}`}>\n <p className=\"sgn-interview__speaker\">\n {speakerLabel(speaker)}\n {speaker?.role && <span className=\"sgn-interview__role\">{speaker.role}</span>}\n </p>\n <div className=\"sgn-interview__text\">\n {children}\n {meta}\n </div>\n </div>\n )\n}\n\n/**\n * One question-and-answer pair.\n *\n * The answer's rich text is passed in as children, so the site keeps using its\n * own Portable Text setup rather than a second one bundled here.\n *\n * @public\n */\nexport function Interview(props: InterviewProps): React.JSX.Element {\n const {value, speakers, source, labels, children, className} = props\n const words = {...DEFAULT_LABELS, ...labels}\n\n const answering = findSpeaker(speakers, value.speaker)\n const asking = askingSpeaker(speakers, value)\n const seconds = timestampSeconds(value.timestamp)\n const link = timestampUrl(source, seconds)\n\n const meta =\n value.timestamp || value.translated ? (\n <p className=\"sgn-interview__meta\">\n {value.timestamp && (\n <span>\n {link && seconds !== undefined ? <a href={link}>{value.timestamp}</a> : value.timestamp}\n </span>\n )}\n {value.translated && <span>{words.translated}</span>}\n </p>\n ) : undefined\n\n return (\n <div className={['sgn-interview', className].filter(Boolean).join(' ')}>\n {value.question && (\n <Turn kind=\"question\" speaker={asking}>\n <p>{value.question}</p>\n </Turn>\n )}\n\n <Turn kind=\"answer\" speaker={answering} meta={meta}>\n {children}\n {value.translated && value.originalAnswer && (\n <p className=\"sgn-interview__original\">\n {words.originalWording}: “{value.originalAnswer}”\n </p>\n )}\n </Turn>\n </div>\n )\n}\n","import {PortableText} from '@portabletext/react'\n\nimport type {InterviewValue} from '../lib/types'\nimport {Interview, type InterviewProps} from './Interview'\n\ntype RenderOptions = Omit<InterviewProps, 'value' | 'children'>\n\n/**\n * Portable Text block renderer for the `interviewBlock` object type.\n *\n * The answer is rendered with `@portabletext/react` unless the site passes its\n * own renderer through `answer`.\n *\n * @public\n */\nexport function InterviewBlock(props: {\n value: InterviewValue\n options?: RenderOptions & {answer?: (value: InterviewValue) => React.ReactNode}\n}): React.JSX.Element | null {\n const {value, options} = props\n const {answer, ...rest} = options || {}\n\n return (\n <Interview value={value} {...rest}>\n {answer ? answer(value) : <PortableText value={value.answer ?? []} />}\n </Interview>\n )\n}\n\n/**\n * Drop-in `components` fragment for `@portabletext/react`.\n *\n * @public\n */\nexport const interviewPortableTextComponents = {\n types: {\n interviewBlock: ({value}: {value: InterviewValue}) => <InterviewBlock value={value} />,\n },\n}\n\n/**\n * Same as {@link interviewPortableTextComponents}, but for a custom type name\n * or with fixed options — the speaker list and the recording URL, typically.\n *\n * @public\n */\nexport function createInterviewPortableTextComponents(\n options?: RenderOptions & {\n typeName?: string\n answer?: (value: InterviewValue) => React.ReactNode\n },\n): {types: Record<string, (props: {value: InterviewValue}) => React.JSX.Element | null>} {\n const {typeName = 'interviewBlock', ...rest} = options || {}\n\n return {\n types: {\n [typeName]: ({value}: {value: InterviewValue}) => (\n <InterviewBlock value={value} options={rest} />\n ),\n },\n }\n}\n","/**\n * Structural CSS for an interview: the speaker column and the turns.\n *\n * No colours, no fonts. Every value worth changing is a custom property with\n * its default at the point of use — a default declared on the block itself\n * would beat the same variable set on an ancestor, so theming from a wrapper\n * would silently do nothing.\n *\n * Set `--sgn-interview-layout: inline` on any ancestor to get the classic\n * `NAME: text` form instead of two columns.\n *\n * @public\n */\nexport const interviewStyles = `\n.sgn-interview {\n margin: 0;\n color: inherit;\n font: inherit;\n}\n\n.sgn-interview__turn {\n display: grid;\n grid-template-columns: var(--sgn-interview-speaker-width, 7.5rem) minmax(0, 1fr);\n gap: var(--sgn-interview-column-gap, 1.25rem);\n align-items: baseline;\n}\n\n.sgn-interview__turn + .sgn-interview__turn {\n margin-top: var(--sgn-interview-turn-gap, 0.75rem);\n}\n\n.sgn-interview__turn--answer + .sgn-interview__turn--question {\n margin-top: var(--sgn-interview-pair-gap, 1.75rem);\n}\n\n.sgn-interview__speaker {\n margin: 0;\n font-size: var(--sgn-interview-speaker-size, 0.8125em);\n font-weight: var(--sgn-interview-speaker-weight, 600);\n letter-spacing: var(--sgn-interview-speaker-tracking, 0.06em);\n text-transform: var(--sgn-interview-speaker-transform, uppercase);\n opacity: var(--sgn-interview-speaker-opacity, 0.72);\n overflow-wrap: break-word;\n}\n\n.sgn-interview__role {\n display: block;\n font-weight: 400;\n text-transform: none;\n letter-spacing: normal;\n opacity: 0.75;\n}\n\n.sgn-interview__text {\n margin: 0;\n}\n\n.sgn-interview__text > p {\n margin: 0;\n}\n\n.sgn-interview__text > p + p {\n margin-top: var(--sgn-interview-paragraph-gap, 0.75em);\n}\n\n.sgn-interview__turn--question .sgn-interview__text {\n font-weight: var(--sgn-interview-question-weight, 600);\n}\n\n.sgn-interview__meta {\n margin: var(--sgn-interview-meta-gap, 0.35rem) 0 0;\n font-size: var(--sgn-interview-meta-size, 0.8125em);\n opacity: var(--sgn-interview-meta-opacity, 0.65);\n}\n\n.sgn-interview__meta > * + *::before {\n content: ' · ';\n}\n\n.sgn-interview__original {\n margin: var(--sgn-interview-meta-gap, 0.35rem) 0 0;\n font-size: var(--sgn-interview-meta-size, 0.8125em);\n font-style: italic;\n opacity: var(--sgn-interview-meta-opacity, 0.65);\n}\n\n/* One column once the speaker column would squeeze the text, and whenever the\n inline layout is asked for. */\n@container (max-width: 30rem) {\n .sgn-interview__turn {\n grid-template-columns: minmax(0, 1fr);\n gap: var(--sgn-interview-stacked-gap, 0.2rem);\n }\n}\n\n@media (max-width: 30rem) {\n .sgn-interview__turn {\n grid-template-columns: minmax(0, 1fr);\n gap: var(--sgn-interview-stacked-gap, 0.2rem);\n }\n}\n`\n\nconst STYLE_ID = 'sgn-interview-styles'\n\n/**\n * Adds {@link interviewStyles} to the document once.\n *\n * Only needed when the stylesheet is not already part of the site's own CSS.\n *\n * @public\n */\nexport function ensureInterviewStyles(): void {\n if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return\n\n const element = document.createElement('style')\n element.id = STYLE_ID\n element.textContent = interviewStyles\n document.head.appendChild(element)\n}\n"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "name": "sanity-plugin-sgntech-interview",
3
+ "version": "1.0.0",
4
+ "description": "Interview blocks for Sanity Studio — question and answer pairs with speakers, timestamps and a note when an answer was translated.",
5
+ "keywords": [
6
+ "sanity",
7
+ "sanity-plugin",
8
+ "interview",
9
+ "portable-text",
10
+ "editorial",
11
+ "qa"
12
+ ],
13
+ "homepage": "https://github.com/netz-sg/sanity-plugin-sgntech-interview#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/netz-sg/sanity-plugin-sgntech-interview/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/netz-sg/sanity-plugin-sgntech-interview.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "SGNTech <kontakt@lifad.world>",
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "source": "./src/index.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./render": {
30
+ "source": "./src/render.ts",
31
+ "default": "./dist/render.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "types": "./dist/index.d.ts",
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "scripts": {
40
+ "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
41
+ "format": "oxfmt",
42
+ "link-watch": "plugin-kit link-watch",
43
+ "lint": "oxlint",
44
+ "prepublishOnly": "npm run build",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "watch": "pkg-utils watch --strict"
48
+ },
49
+ "dependencies": {
50
+ "@sanity/icons": "^5.2.1"
51
+ },
52
+ "devDependencies": {
53
+ "@sanity/browserslist-config": "^1.0.5",
54
+ "@sanity/pkg-utils": "^11.0.17",
55
+ "@sanity/plugin-kit": "^10.0.3",
56
+ "@types/react": "^19.2.18",
57
+ "oxfmt": "^0.61.0",
58
+ "oxlint": "^1.77.0",
59
+ "oxlint-tsgolint": "^7.0.2001",
60
+ "react": "^19.2.8",
61
+ "react-dom": "^19.2.8",
62
+ "sanity": "^6.9.0",
63
+ "styled-components": "^6.5.0",
64
+ "typescript": "^6.0.3",
65
+ "vitest": "^3.2.4",
66
+ "@portabletext/react": "^7.0.1",
67
+ "@portabletext/types": "^4.0.2"
68
+ },
69
+ "peerDependencies": {
70
+ "react": "^18 || ^19",
71
+ "sanity": "^5 || ^6.0.0-0",
72
+ "@portabletext/react": "^3 || ^4 || ^5 || ^6 || ^7"
73
+ },
74
+ "engines": {
75
+ "node": ">=20.19 <22 || >=22.12"
76
+ },
77
+ "browserslist": "extends @sanity/browserslist-config",
78
+ "publishConfig": {
79
+ "exports": {
80
+ ".": {
81
+ "default": "./dist/index.js"
82
+ },
83
+ "./render": {
84
+ "default": "./dist/render.js"
85
+ },
86
+ "./package.json": "./package.json"
87
+ }
88
+ },
89
+ "allowScripts": {
90
+ "esbuild@0.28.1": true
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@portabletext/react": {
94
+ "optional": true
95
+ }
96
+ }
97
+ }