@hajimism/dek 0.5.0 → 0.6.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +12 -5
  3. package/src/cli/address.ts +48 -11
  4. package/src/cli/build.ts +3 -1
  5. package/src/cli/commands.ts +24 -10
  6. package/src/cli/contract.ts +28 -2
  7. package/src/cli/flags.ts +5 -0
  8. package/src/cli/keep-alive.ts +12 -6
  9. package/src/cli/lint.ts +53 -1
  10. package/src/cli/main.ts +2 -2
  11. package/src/cli/sync.ts +21 -6
  12. package/src/cli/text.ts +20 -2
  13. package/src/cli/theme.ts +1 -1
  14. package/src/core/build.ts +4 -0
  15. package/src/core/config-keys.ts +7 -2
  16. package/src/core/config.ts +63 -0
  17. package/src/core/diagnostic.ts +9 -1
  18. package/src/core/document.ts +28 -3
  19. package/src/core/lint/context.ts +2 -0
  20. package/src/core/lint/shared-styles.ts +26 -1
  21. package/src/core/lint/theme-css.ts +64 -3
  22. package/src/core/lint.ts +15 -2
  23. package/src/core/page.ts +1 -0
  24. package/src/core/plugin-host.ts +669 -0
  25. package/src/core/plugin.ts +436 -0
  26. package/src/core/sandbox.ts +1 -1
  27. package/src/core/sarif.ts +47 -5
  28. package/src/core/slide-measure.ts +1 -0
  29. package/src/core/spawn.ts +9 -7
  30. package/src/core/sync.ts +3 -1
  31. package/src/plugin-api.ts +126 -0
  32. package/src/plugins/study/check.ts +128 -0
  33. package/src/plugins/study/main.ts +47 -0
  34. package/src/plugins/study/manifest.ts +32 -0
  35. package/src/plugins/study/page.ts +295 -0
  36. package/src/plugins/study/place.ts +88 -0
  37. package/src/plugins/study/sarif.ts +30 -0
  38. package/src/plugins/study/study.ts +137 -0
  39. package/src/plugins/study/text.ts +55 -0
  40. package/src/plugins/study/yaml.ts +226 -0
  41. package/src/plugins/words/check.ts +109 -0
  42. package/src/plugins/words/lines.ts +26 -0
  43. package/src/plugins/words/list.ts +288 -0
  44. package/src/plugins/words/main.ts +50 -0
  45. package/src/plugins/words/manifest.ts +36 -0
  46. package/src/plugins/words/match.ts +60 -0
  47. package/src/plugins/words/sarif.ts +37 -0
  48. package/src/plugins/words/text.ts +352 -0
  49. package/src/plugins/words/yaml.ts +225 -0
  50. package/src/runtime/player.ts +18 -2
  51. package/src/runtime/plugins.ts +236 -0
  52. package/src/runtime/start.ts +16 -1
  53. package/src/runtime/window.ts +3 -0
  54. package/src/server/deck-snapshot.ts +32 -1
  55. package/src/server/dev.ts +9 -3
  56. package/src/server/lock.ts +39 -9
  57. package/src/server/pages.ts +9 -1
  58. package/tsconfig.json +1 -1
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Where each sticker sits in a slide's words, decided the same in the check and in the page, so a
3
+ * deck that passes lint is a deck whose stickers the page finds.
4
+ */
5
+
6
+ /** What a sticker needs to be placed: its words, the words right before them, and its line. */
7
+ export type Placeable = { quote: string; prefix?: string; line: number };
8
+
9
+ export type Placement<S extends Placeable> = {
10
+ /** Each sticker that found its place, with where its words start in the text. */
11
+ placed: Array<{ sticker: S; at: number }>;
12
+ /** Each sticker whose words are not where it says. */
13
+ lost: S[];
14
+ /** Words a sticker hides that show again with no sticker over them: how often, and the first sticker. */
15
+ shown: Array<{ quote: string; first: S; count: number }>;
16
+ };
17
+
18
+ /** Text with each run of whitespace as one space, trimmed, as words are compared. */
19
+ export function normalize(text: string): string {
20
+ return text.replace(/\s+/g, " ").trim();
21
+ }
22
+
23
+ /**
24
+ * Places each sticker in `text`, a slide's words as `normalize` leaves them. Stickers over the same
25
+ * words take their places in order: one with a prefix the first place that follows it, then one
26
+ * without the first place left.
27
+ */
28
+ export function placeStickers<S extends Placeable>(text: string, stickers: S[]): Placement<S> {
29
+ const placement: Placement<S> = { placed: [], lost: [], shown: [] };
30
+ const inOrder = stickers.toSorted((a, b) => a.line - b.line);
31
+ for (const [quote, same] of Map.groupBy(inOrder, (sticker) => normalize(sticker.quote))) {
32
+ const places = occurrences(text, quote);
33
+ const taken = new Set<number>();
34
+ const placed: S[] = [];
35
+ for (const sticker of [...same.filter((s) => s.prefix), ...same.filter((s) => !s.prefix)]) {
36
+ const prefix = sticker.prefix === undefined ? undefined : normalize(sticker.prefix);
37
+ const at = places.find(
38
+ (place) => !taken.has(place) && (prefix === undefined || follows(text, place, prefix)),
39
+ );
40
+ if (at === undefined) {
41
+ placement.lost.push(sticker);
42
+ continue;
43
+ }
44
+ taken.add(at);
45
+ placed.push(sticker);
46
+ placement.placed.push({ sticker, at });
47
+ }
48
+ const count = places.length - taken.size;
49
+ const first = placed.toSorted((a, b) => a.line - b.line)[0];
50
+ if (count > 0 && first) {
51
+ placement.shown.push({ quote, first, count });
52
+ }
53
+ }
54
+ placement.placed.sort((a, b) => a.sticker.line - b.sticker.line);
55
+ return placement;
56
+ }
57
+
58
+ /** Whether the words right before `place` in `text` end with `prefix`, as normalized. */
59
+ export function follows(text: string, place: number, prefix: string): boolean {
60
+ return text.slice(0, place).trimEnd().endsWith(prefix);
61
+ }
62
+
63
+ /**
64
+ * A letter or digit of a script written with spaces between words. Next to one, a quote that
65
+ * starts or ends with one is part of a longer word: `is` is not in "This". Japanese, Chinese,
66
+ * Korean, and Thai run on without spaces, so a quote is any run of their words.
67
+ */
68
+ const SPACED = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}]/u;
69
+
70
+ /** Where `quote` starts in `text`, each place it does, without overlaps, as whole words. */
71
+ export function occurrences(text: string, quote: string): number[] {
72
+ const found: number[] = [];
73
+ if (quote === "") {
74
+ return found;
75
+ }
76
+ const end = quote.length;
77
+ const opens = SPACED.test(quote[0] ?? "");
78
+ const closes = SPACED.test(quote[end - 1] ?? "");
79
+ for (let at = text.indexOf(quote); at !== -1; ) {
80
+ const whole =
81
+ !(opens && SPACED.test(text[at - 1] ?? "")) && !(closes && SPACED.test(text[at + end] ?? ""));
82
+ if (whole) {
83
+ found.push(at);
84
+ }
85
+ at = text.indexOf(quote, whole ? at + end : at + 1);
86
+ }
87
+ return found;
88
+ }
@@ -0,0 +1,30 @@
1
+ import type { CheckOutput } from "../../plugin-api.ts";
2
+ import type { Finding } from "./check.ts";
3
+ import type { RuleId } from "./manifest.ts";
4
+ import { MANIFEST } from "./manifest.ts";
5
+
6
+ /** Findings as the SARIF dek reads: the rule, the message, the place, and the hint. */
7
+ export function toSarif(findings: Finding[]): CheckOutput<RuleId> {
8
+ return {
9
+ version: "2.1.0",
10
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
11
+ runs: [
12
+ {
13
+ tool: { driver: { name: "dek-study", version: MANIFEST.version } },
14
+ results: findings.map((finding) => ({
15
+ ruleId: finding.rule,
16
+ message: { text: finding.message },
17
+ locations: [
18
+ {
19
+ physicalLocation: {
20
+ artifactLocation: { uri: finding.path },
21
+ region: { startLine: finding.line },
22
+ },
23
+ },
24
+ ],
25
+ properties: { hint: finding.hint },
26
+ })),
27
+ },
28
+ ],
29
+ };
30
+ }
@@ -0,0 +1,137 @@
1
+ import { STUDY_FILE } from "./manifest.ts";
2
+ import { parseYaml, YamlError } from "./yaml.ts";
3
+
4
+ /** One sticker: it hides `quote` on `slide`, at the place `prefix` comes right before. */
5
+ export type Sticker = {
6
+ id: string;
7
+ slide: string;
8
+ quote: string;
9
+ prefix?: string;
10
+ /** Where the sticker starts in study.yaml. */
11
+ line: number;
12
+ };
13
+
14
+ /** What is wrong with study.yaml, and the line to look at. */
15
+ export type Problem = { message: string; line: number };
16
+
17
+ const VERSION = 1;
18
+
19
+ /** What YAML made of words written without quotes, when it made them something other than text. */
20
+ function unquoted(value: unknown): string | undefined {
21
+ if (typeof value === "number") {
22
+ return `the number ${value}`;
23
+ }
24
+ return typeof value === "boolean" ? String(value) : undefined;
25
+ }
26
+
27
+ /**
28
+ * The stickers in study.yaml, and what keeps the rest from being used. A sticker with a problem
29
+ * is left out, so the checks run on every sticker that can be read. The page reads it the same.
30
+ */
31
+ export function readStudy(source: string): { stickers: Sticker[]; problems: Problem[] } {
32
+ let parsed: ReturnType<typeof parseYaml>;
33
+ try {
34
+ parsed = parseYaml(source);
35
+ } catch (error) {
36
+ if (error instanceof YamlError) {
37
+ return {
38
+ stickers: [],
39
+ problems: [
40
+ {
41
+ message: `${STUDY_FILE} is not YAML dek-study reads: ${error.message}`,
42
+ line: error.line,
43
+ },
44
+ ],
45
+ };
46
+ }
47
+ throw error;
48
+ }
49
+ const { value, lineOf } = parsed;
50
+ if (value === null) {
51
+ return { stickers: [], problems: [] };
52
+ }
53
+ if (typeof value !== "object" || Array.isArray(value)) {
54
+ return { stickers: [], problems: [{ message: `${STUDY_FILE} is not a mapping`, line: 1 }] };
55
+ }
56
+ const top = lineOf(value) ?? 1;
57
+ const fields = value as { version?: unknown; stickers?: unknown };
58
+ const lines = source.split("\n");
59
+ const keyLine = (key: string) => {
60
+ const index = lines.findIndex((text) => new RegExp(`^${key}\\s*:`).test(text));
61
+ return index === -1 ? top : index + 1;
62
+ };
63
+ if (fields.version !== VERSION) {
64
+ return {
65
+ stickers: [],
66
+ problems: [
67
+ {
68
+ message: `${STUDY_FILE} is version ${String(fields.version)}; this dek-study reads version ${VERSION}`,
69
+ line: keyLine("version"),
70
+ },
71
+ ],
72
+ };
73
+ }
74
+ if (fields.stickers === undefined || fields.stickers === null) {
75
+ return { stickers: [], problems: [] };
76
+ }
77
+ if (!Array.isArray(fields.stickers)) {
78
+ return {
79
+ stickers: [],
80
+ problems: [{ message: "`stickers` is not a list", line: keyLine("stickers") }],
81
+ };
82
+ }
83
+
84
+ const stickers: Sticker[] = [];
85
+ const problems: Problem[] = [];
86
+ const seen = new Set<string>();
87
+ fields.stickers.forEach((entry: unknown, index) => {
88
+ const sticker =
89
+ typeof entry === "object" && entry !== null ? (entry as Record<string, unknown>) : null;
90
+ const line = (sticker && lineOf(sticker)) ?? keyLine("stickers");
91
+ const id = typeof sticker?.id === "string" && sticker.id !== "" ? sticker.id : undefined;
92
+ const name = id ?? `#${index + 1}`;
93
+ for (const key of ["id", "slide", "quote", "prefix"] as const) {
94
+ const made = unquoted(sticker?.[key]);
95
+ if (made !== undefined) {
96
+ problems.push({
97
+ message: `sticker ${name}: YAML reads its \`${key}\` as ${made}; put it in quotes`,
98
+ line,
99
+ });
100
+ return;
101
+ }
102
+ }
103
+ if (id === undefined) {
104
+ problems.push({ message: `sticker ${name} has no \`id\``, line });
105
+ return;
106
+ }
107
+ for (const key of ["slide", "quote"] as const) {
108
+ const value = sticker?.[key];
109
+ if (typeof value !== "string" || value.trim() === "") {
110
+ problems.push({ message: `sticker ${name} has no \`${key}\``, line });
111
+ return;
112
+ }
113
+ }
114
+ if (sticker?.prefix !== undefined && typeof sticker.prefix !== "string") {
115
+ problems.push({ message: `sticker ${name}'s \`prefix\` is not text`, line });
116
+ return;
117
+ }
118
+ if (seen.has(id)) {
119
+ problems.push({
120
+ message: `sticker ${id} is named twice; give each sticker its own id`,
121
+ line,
122
+ });
123
+ return;
124
+ }
125
+ seen.add(id);
126
+ stickers.push({
127
+ id,
128
+ slide: sticker?.slide as string,
129
+ quote: sticker?.quote as string,
130
+ ...(typeof sticker?.prefix === "string" && sticker.prefix !== ""
131
+ ? { prefix: sticker.prefix }
132
+ : {}),
133
+ line,
134
+ });
135
+ });
136
+ return { stickers, problems };
137
+ }
@@ -0,0 +1,55 @@
1
+ import { normalize } from "./place.ts";
2
+
3
+ /** Elements whose content no reader sees as words on the slide. */
4
+ const UNSEEN = "script, style, template, noscript";
5
+
6
+ const ENTITIES: Record<string, string> = {
7
+ amp: "&",
8
+ lt: "<",
9
+ gt: ">",
10
+ quot: '"',
11
+ apos: "'",
12
+ nbsp: " ",
13
+ };
14
+
15
+ /**
16
+ * A slide's words as a reader sees them, in document order, with each run of whitespace as one
17
+ * space: the text dek-study matches a sticker's words against.
18
+ */
19
+ export async function slideText(html: string): Promise<string> {
20
+ const parts: string[] = [];
21
+ // How many unseen elements the parser is inside; text there is not the slide's words.
22
+ let unseen = 0;
23
+ await new HTMLRewriter()
24
+ .on(UNSEEN, {
25
+ element(element) {
26
+ unseen += 1;
27
+ element.onEndTag(() => {
28
+ unseen -= 1;
29
+ });
30
+ },
31
+ })
32
+ .onDocument({
33
+ text(chunk) {
34
+ if (unseen === 0) {
35
+ parts.push(chunk.text);
36
+ }
37
+ },
38
+ })
39
+ .transform(new Response(html))
40
+ .text();
41
+ return normalize(decode(parts.join("")));
42
+ }
43
+
44
+ function decode(text: string): string {
45
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name: string) => {
46
+ if (name.startsWith("#")) {
47
+ const code =
48
+ name[1] === "x" || name[1] === "X"
49
+ ? Number.parseInt(name.slice(2), 16)
50
+ : Number(name.slice(1));
51
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
52
+ }
53
+ return ENTITIES[name.toLowerCase()] ?? whole;
54
+ });
55
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The YAML study.yaml is written in, read the same in the check and in the page, which has no
3
+ * YAML parser: block mappings and sequences of plain words as keys, and scalars that are plain,
4
+ * single-quoted, or double-quoted, `[]`, or `{}`. Anything else is refused with its line, so a file
5
+ * the check passes is a file the page reads.
6
+ */
7
+
8
+ /** YAML this reader does not read, and the line it starts on. */
9
+ export class YamlError extends Error {
10
+ constructor(
11
+ message: string,
12
+ readonly line: number,
13
+ ) {
14
+ super(message);
15
+ }
16
+ }
17
+
18
+ type Line = { indent: number; text: string; line: number };
19
+
20
+ /** The value, and the line each mapping in it starts on. */
21
+ export function parseYaml(source: string): {
22
+ value: unknown;
23
+ lineOf: (mapping: object) => number | undefined;
24
+ } {
25
+ const lines = toLines(source);
26
+ const starts = new WeakMap<object, number>();
27
+ const lineOf = (mapping: object) => starts.get(mapping);
28
+ const first = lines[0];
29
+ if (first === undefined) {
30
+ return { value: null, lineOf };
31
+ }
32
+ const [value, next] = block(lines, 0, first.indent, starts);
33
+ const extra = lines[next];
34
+ if (extra !== undefined) {
35
+ throw new YamlError("this line is indented less than the lines before it", extra.line);
36
+ }
37
+ return { value, lineOf };
38
+ }
39
+
40
+ function toLines(source: string): Line[] {
41
+ // A byte order mark, as Notepad on Windows saves, is no part of the first line.
42
+ return source
43
+ .replace(/^\uFEFF/, "")
44
+ .split(/\r?\n/)
45
+ .flatMap((raw, index): Line[] => {
46
+ const line = index + 1;
47
+ const text = withoutComment(raw);
48
+ if (text.trim() === "") {
49
+ return [];
50
+ }
51
+ const indent = text.length - text.trimStart().length;
52
+ if (/^\s*\t/.test(text) || text.slice(0, indent).includes("\t")) {
53
+ throw new YamlError("indent with spaces, not tabs", line);
54
+ }
55
+ return [{ indent, text: text.trim(), line }];
56
+ });
57
+ }
58
+
59
+ /**
60
+ * The line up to a `#` that starts a comment: at the start, or after a space, outside quotes. A
61
+ * quote opens a quoted value only where a value starts, so `don't` in a plain value opens none.
62
+ */
63
+ function withoutComment(raw: string): string {
64
+ let quote: string | undefined;
65
+ for (let i = 0; i < raw.length; i += 1) {
66
+ const char = raw[i];
67
+ if (quote === '"' && char === "\\") {
68
+ i += 1;
69
+ } else if (quote === "'" && char === "'" && raw[i + 1] === "'") {
70
+ i += 1;
71
+ } else if (quote !== undefined) {
72
+ if (char === quote) {
73
+ quote = undefined;
74
+ }
75
+ } else if ((char === '"' || char === "'") && VALUE_START.test(raw.slice(0, i))) {
76
+ quote = char;
77
+ } else if (char === "#" && (i === 0 || /\s/.test(raw[i - 1] ?? ""))) {
78
+ return raw.slice(0, i);
79
+ }
80
+ }
81
+ return raw;
82
+ }
83
+
84
+ /** What comes before a value: the line's start, a key's colon, or an item's dash. */
85
+ const VALUE_START = /(?:^|:|(?:^|\s)-)\s*$/;
86
+
87
+ const ITEM = /^-(?:\s+(.*))?$/;
88
+ const KEY = /^([A-Za-z0-9_-]+)\s*:(?:\s+(.*))?$/;
89
+
90
+ function block(
91
+ lines: Line[],
92
+ start: number,
93
+ indent: number,
94
+ starts: WeakMap<object, number>,
95
+ ): [unknown, number] {
96
+ const line = lines[start] as Line;
97
+ return ITEM.test(line.text)
98
+ ? sequence(lines, start, indent, starts)
99
+ : mapping(lines, start, indent, starts);
100
+ }
101
+
102
+ function sequence(
103
+ lines: Line[],
104
+ start: number,
105
+ indent: number,
106
+ starts: WeakMap<object, number>,
107
+ ): [unknown[], number] {
108
+ const items: unknown[] = [];
109
+ let i = start;
110
+ for (let line = lines[i]; line?.indent === indent; line = lines[i]) {
111
+ const match = line.text.match(ITEM);
112
+ if (!match) {
113
+ break;
114
+ }
115
+ const rest = match[1];
116
+ if (rest === undefined) {
117
+ // `-` alone: the item is the block below it.
118
+ const below = lines[i + 1];
119
+ if (below !== undefined && below.indent > indent) {
120
+ const [value, next] = block(lines, i + 1, below.indent, starts);
121
+ items.push(value);
122
+ i = next;
123
+ } else {
124
+ items.push(null);
125
+ i += 1;
126
+ }
127
+ } else if (KEY.test(rest)) {
128
+ // `- key: value` starts a mapping whose keys line up after the dash.
129
+ const column = indent + (line.text.length - rest.length);
130
+ const shifted = [...lines];
131
+ shifted[i] = { indent: column, text: rest, line: line.line };
132
+ const [value, next] = mapping(shifted, i, column, starts);
133
+ items.push(value);
134
+ i = next;
135
+ } else {
136
+ items.push(scalar(rest, line.line));
137
+ i += 1;
138
+ }
139
+ }
140
+ return [items, i];
141
+ }
142
+
143
+ function mapping(
144
+ lines: Line[],
145
+ start: number,
146
+ indent: number,
147
+ starts: WeakMap<object, number>,
148
+ ): [Record<string, unknown>, number] {
149
+ const result: Record<string, unknown> = {};
150
+ starts.set(result, (lines[start] as Line).line);
151
+ let i = start;
152
+ for (let line = lines[i]; line?.indent === indent; line = lines[i]) {
153
+ const match = line.text.match(KEY);
154
+ if (!match) {
155
+ throw new YamlError("expected `key: value`", line.line);
156
+ }
157
+ const key = match[1] as string;
158
+ if (Object.hasOwn(result, key)) {
159
+ throw new YamlError(`\`${key}\` is written twice`, line.line);
160
+ }
161
+ const rest = match[2];
162
+ const below = lines[i + 1];
163
+ if (rest !== undefined) {
164
+ result[key] = scalar(rest, line.line);
165
+ i += 1;
166
+ } else if (below !== undefined && below.indent > indent) {
167
+ [result[key], i] = block(lines, i + 1, below.indent, starts);
168
+ } else if (below !== undefined && below.indent === indent && ITEM.test(below.text)) {
169
+ // A sequence may sit at its key's own indent.
170
+ [result[key], i] = sequence(lines, i + 1, indent, starts);
171
+ } else {
172
+ result[key] = null;
173
+ i += 1;
174
+ }
175
+ }
176
+ const deeper = lines[i];
177
+ if (deeper !== undefined && deeper.indent > indent) {
178
+ throw new YamlError("this line is indented more than the lines before it", deeper.line);
179
+ }
180
+ return [result, i];
181
+ }
182
+
183
+ function scalar(text: string, line: number): unknown {
184
+ if (text.startsWith('"')) {
185
+ return quoted(text, line, /^"((?:[^"\\]|\\.)*)"$/, (inner) => JSON.parse(`"${inner}"`));
186
+ }
187
+ if (text.startsWith("'")) {
188
+ return quoted(text, line, /^'((?:[^']|'')*)'$/, (inner) => inner.replaceAll("''", "'"));
189
+ }
190
+ if (text === "[]") {
191
+ return [];
192
+ }
193
+ if (text === "{}") {
194
+ return {};
195
+ }
196
+ if (/^[[{&*!|>%@`]/.test(text)) {
197
+ throw new YamlError(`dek-study reads no YAML that starts a value with ${text[0]}`, line);
198
+ }
199
+ if (/^-?\d+$/.test(text)) {
200
+ return Number(text);
201
+ }
202
+ if (text === "null" || text === "~") {
203
+ return null;
204
+ }
205
+ if (text === "true" || text === "false") {
206
+ return text === "true";
207
+ }
208
+ return text;
209
+ }
210
+
211
+ function quoted(
212
+ text: string,
213
+ line: number,
214
+ pattern: RegExp,
215
+ read: (inner: string) => string,
216
+ ): string {
217
+ const inner = text.match(pattern)?.[1];
218
+ if (inner === undefined) {
219
+ throw new YamlError("a quoted value must end with its quote on the same line", line);
220
+ }
221
+ try {
222
+ return read(inner);
223
+ } catch {
224
+ throw new YamlError("a double-quoted value has an escape dek-study does not read", line);
225
+ }
226
+ }
@@ -0,0 +1,109 @@
1
+ import { join } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import type { CheckInput } from "../../plugin-api.ts";
4
+ import { lineLocator } from "./lines.ts";
5
+ import { type Entry, listedWords, type Problem, readAllows, readList } from "./list.ts";
6
+ import { ALLOW_FILE, LIST_FILE, LIST_PATH, type RuleId } from "./manifest.ts";
7
+ import { finder } from "./match.ts";
8
+ import { type Block, scriptBlocks, slideBlocks } from "./text.ts";
9
+
10
+ /**
11
+ * A finding, which can name only a rule the manifest declares. A path is relative to the deck, or
12
+ * a file URL for the project's list.
13
+ */
14
+ export type Finding = {
15
+ rule: RuleId;
16
+ message: string;
17
+ path: string;
18
+ /** Left out for a file that is not there. */
19
+ line?: number;
20
+ column?: number;
21
+ hint: string;
22
+ };
23
+
24
+ /** Reads a file by its path, relative to the deck's folder or absolute; undefined when absent. */
25
+ export type ReadFile = (path: string) => Promise<string | undefined>;
26
+
27
+ const GUIDE = "https://hajimism.github.io/dek/reference/plugins.html#dek-words";
28
+
29
+ /**
30
+ * Checks the deck against the project's word list: each listed word in the slides and the script
31
+ * (WORDS001), and the list and the deck's allows reading (WORDS002). A project with no list is
32
+ * WORDS002, since a check that looked for nothing is no pass; a dek that does not say where the
33
+ * list is has nothing to check.
34
+ */
35
+ export async function check(input: CheckInput, read: ReadFile): Promise<Finding[]> {
36
+ if (input.pluginDir === undefined) {
37
+ return [];
38
+ }
39
+ const listPath = join(input.pluginDir, LIST_FILE);
40
+ const list = await read(listPath);
41
+ if (list === undefined) {
42
+ return [
43
+ {
44
+ rule: "WORDS002",
45
+ message: `${LIST_PATH} is not there, and [plugins.words] checks each deck against it`,
46
+ path: pathToFileURL(listPath).href,
47
+ hint: `ask the user for the team's words and write them there as ${GUIDE} shows, or take [plugins.words] out of dek.toml`,
48
+ },
49
+ ];
50
+ }
51
+ const misread =
52
+ (path: string, shown: string) =>
53
+ (problem: Problem): Finding => ({
54
+ rule: "WORDS002",
55
+ message: problem.message,
56
+ path,
57
+ line: problem.line,
58
+ hint: `write ${shown} as ${GUIDE} shows`,
59
+ });
60
+ const { entries, problems } = readList(list);
61
+ const findings = problems.map(misread(pathToFileURL(listPath).href, LIST_PATH));
62
+ const allowSource = await read(ALLOW_FILE);
63
+ const allowed =
64
+ allowSource === undefined ? undefined : readAllows(allowSource, listedWords(list));
65
+ findings.push(...(allowed?.problems ?? []).map(misread(ALLOW_FILE, ALLOW_FILE)));
66
+
67
+ const active = entries
68
+ .filter((entry) => !allowed?.allows.has(entry.word))
69
+ .map((entry) => ({ entry, find: finder(entry) }));
70
+ const look = (path: string, source: string, blocks: Block[], where: Entry["in"]) => {
71
+ const locate = lineLocator(source);
72
+ for (const block of blocks) {
73
+ for (const { entry, find } of active) {
74
+ if (entry.in !== undefined && entry.in !== where) {
75
+ continue;
76
+ }
77
+ for (const found of find(block.text)) {
78
+ const as = found.text === entry.word ? "" : `, as "${found.text}"`;
79
+ findings.push({
80
+ rule: "WORDS001",
81
+ message: `${entry.word} is on the word list${as}`,
82
+ path,
83
+ ...locate(block.at[found.index] ?? 0),
84
+ hint: entry.use ?? "write the sentence without it",
85
+ });
86
+ }
87
+ }
88
+ }
89
+ };
90
+
91
+ const script = await read("script.md");
92
+ if (script !== undefined) {
93
+ look("script.md", script, scriptBlocks(script), "script");
94
+ }
95
+ for (const slide of input.deck.slides) {
96
+ const html = slide.html === null ? undefined : await read(slide.html);
97
+ if (slide.html !== null && html !== undefined) {
98
+ look(slide.html, html, slideBlocks(html), "slides");
99
+ }
100
+ }
101
+ // The same deck gives the same output, in the same order.
102
+ return findings.sort(
103
+ (a, b) =>
104
+ a.path.localeCompare(b.path) ||
105
+ (a.line ?? 0) - (b.line ?? 0) ||
106
+ (a.column ?? 0) - (b.column ?? 0) ||
107
+ a.rule.localeCompare(b.rule),
108
+ );
109
+ }
@@ -0,0 +1,26 @@
1
+ /** Where an offset into a source is written: 1-based line, and 1-based column in UTF-16 units. */
2
+ export type LineColumn = { line: number; column: number };
3
+
4
+ /**
5
+ * Turns offsets into `source` into lines and columns, from one pass over its newlines: the place
6
+ * dek reads a finding at. A copy of dek's own, since an official plugin shares no code (ADR 34).
7
+ */
8
+ export function lineLocator(source: string): (offset: number) => LineColumn {
9
+ const starts = [0];
10
+ for (let index = source.indexOf("\n"); index !== -1; index = source.indexOf("\n", index + 1)) {
11
+ starts.push(index + 1);
12
+ }
13
+ return (offset) => {
14
+ let low = 0;
15
+ let high = starts.length - 1;
16
+ while (low < high) {
17
+ const mid = (low + high + 1) >> 1;
18
+ if ((starts[mid] ?? 0) <= offset) {
19
+ low = mid;
20
+ } else {
21
+ high = mid - 1;
22
+ }
23
+ }
24
+ return { line: low + 1, column: offset - (starts[low] ?? 0) + 1 };
25
+ };
26
+ }