@hajimism/dek 0.5.1 → 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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +10 -3
  3. package/src/cli/build.ts +3 -1
  4. package/src/cli/commands.ts +24 -10
  5. package/src/cli/contract.ts +28 -2
  6. package/src/cli/flags.ts +5 -0
  7. package/src/cli/lint.ts +53 -1
  8. package/src/cli/sync.ts +21 -6
  9. package/src/cli/text.ts +20 -2
  10. package/src/core/build.ts +4 -0
  11. package/src/core/config-keys.ts +7 -2
  12. package/src/core/config.ts +63 -0
  13. package/src/core/diagnostic.ts +9 -1
  14. package/src/core/document.ts +28 -3
  15. package/src/core/page.ts +1 -0
  16. package/src/core/plugin-host.ts +669 -0
  17. package/src/core/plugin.ts +436 -0
  18. package/src/core/sarif.ts +47 -5
  19. package/src/core/sync.ts +3 -1
  20. package/src/plugin-api.ts +126 -0
  21. package/src/plugins/study/check.ts +128 -0
  22. package/src/plugins/study/main.ts +47 -0
  23. package/src/plugins/study/manifest.ts +32 -0
  24. package/src/plugins/study/page.ts +295 -0
  25. package/src/plugins/study/place.ts +88 -0
  26. package/src/plugins/study/sarif.ts +30 -0
  27. package/src/plugins/study/study.ts +137 -0
  28. package/src/plugins/study/text.ts +55 -0
  29. package/src/plugins/study/yaml.ts +226 -0
  30. package/src/plugins/words/check.ts +109 -0
  31. package/src/plugins/words/lines.ts +26 -0
  32. package/src/plugins/words/list.ts +288 -0
  33. package/src/plugins/words/main.ts +50 -0
  34. package/src/plugins/words/manifest.ts +36 -0
  35. package/src/plugins/words/match.ts +60 -0
  36. package/src/plugins/words/sarif.ts +37 -0
  37. package/src/plugins/words/text.ts +352 -0
  38. package/src/plugins/words/yaml.ts +225 -0
  39. package/src/runtime/player.ts +18 -2
  40. package/src/runtime/plugins.ts +236 -0
  41. package/src/runtime/start.ts +16 -1
  42. package/src/runtime/window.ts +3 -0
  43. package/src/server/deck-snapshot.ts +32 -1
  44. package/src/server/dev.ts +7 -1
  45. package/src/server/pages.ts +9 -1
@@ -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
+ }