ais6-app 0.1.0 → 0.2.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,105 @@
1
+ import { render } from "preact";
2
+ import {
3
+ connect,
4
+ exportFile,
5
+ onLanguage,
6
+ onLifecycle,
7
+ onMenuCommand,
8
+ pushMenuState,
9
+ readState,
10
+ setTitle,
11
+ writeState,
12
+ } from "./bridge/bridge.ts";
13
+ import { type Language, translate } from "./strings/index.ts";
14
+ import { App } from "./ui/App.tsx";
15
+ import "./styles/app.css";
16
+
17
+ const NOTE_KEY = "note";
18
+ // Menu actions in app.manifest.json; the app id in front keeps them apart from the desk's own.
19
+ const CLEAR = "__APP_ID__-clear";
20
+ const EXPORT = "__APP_ID__-export";
21
+ const SAVE_DELAY_MS = 400;
22
+
23
+ async function main(): Promise<void> {
24
+ const root = document.getElementById("app");
25
+ if (!root) return;
26
+ const client = await connect();
27
+ let language: Language = client.welcome.language === "zh" ? "zh" : "en";
28
+ const stored = await readState(client, NOTE_KEY);
29
+ let note = typeof stored === "string" ? stored : "";
30
+ let saved = true;
31
+ let timer: ReturnType<typeof setTimeout> | undefined;
32
+
33
+ const save = async () => {
34
+ clearTimeout(timer);
35
+ timer = undefined;
36
+ saved = await writeState(client, NOTE_KEY, note);
37
+ draw();
38
+ };
39
+ const menus = () =>
40
+ pushMenuState(client, {
41
+ available: { [CLEAR]: note.length > 0, [EXPORT]: note.length > 0, "close-active-window": true },
42
+ checked: {},
43
+ });
44
+ const draw = () => {
45
+ document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
46
+ render(
47
+ <App
48
+ language={language}
49
+ note={note}
50
+ saved={saved}
51
+ standalone={client.standalone}
52
+ onInput={(text) => {
53
+ note = text;
54
+ saved = false;
55
+ clearTimeout(timer);
56
+ timer = setTimeout(save, SAVE_DELAY_MS);
57
+ menus();
58
+ draw();
59
+ }}
60
+ />,
61
+ root,
62
+ );
63
+ };
64
+
65
+ onMenuCommand(client, (action) => {
66
+ if (action === CLEAR) {
67
+ note = "";
68
+ void save();
69
+ menus();
70
+ return true;
71
+ }
72
+ if (action === EXPORT) {
73
+ void exportFile(
74
+ client,
75
+ `${translate(language, "app_title")}.txt`,
76
+ "text/plain",
77
+ new Blob([note], { type: "text/plain" }),
78
+ );
79
+ return true;
80
+ }
81
+ return false;
82
+ });
83
+ // The desk asks before it closes or hides the window: finish the pending save first.
84
+ onLifecycle(client, {
85
+ suspend: async () => {
86
+ if (timer !== undefined) await save();
87
+ },
88
+ resume: () => draw(),
89
+ dispose: () => clearTimeout(timer),
90
+ });
91
+ onLanguage(client, (next) => {
92
+ language = next;
93
+ setTitle(client, translate(language, "app_title"));
94
+ draw();
95
+ });
96
+ // Lets the desk keep a picture of the window for the Dock and the window list.
97
+ client.enableSnapshots({ producer: "dom", target: root });
98
+ setTitle(client, translate(language, "app_title"));
99
+ menus();
100
+ draw();
101
+ // First paint is done: the desk stops its loading state and takes the first picture.
102
+ client.ready();
103
+ }
104
+
105
+ void main();
@@ -0,0 +1,15 @@
1
+ // English strings. The key type of the app comes from this table, so a
2
+ // misspelled key does not compile. Window titles and menu labels also live in
3
+ // app.manifest.json, because the desk draws those before the frame loads.
4
+ export const en = {
5
+ app_title: "__NAME__",
6
+ note_label: "Note",
7
+ note_placeholder: "Write anything. It is kept when the window closes.",
8
+ word_count: "{count} words",
9
+ saved: "Saved",
10
+ standalone: "Running on its own page: notes stay in this browser.",
11
+ clear: "Clear",
12
+ export_text: "Export as Text",
13
+ } as const;
14
+
15
+ export type StringKey = keyof typeof en;
@@ -0,0 +1,23 @@
1
+ import { en, type StringKey } from "./en.ts";
2
+ import { zh } from "./zh.ts";
3
+
4
+ export type { StringKey };
5
+ export type Language = "en" | "zh";
6
+
7
+ const tables: Record<Language, Record<StringKey, string>> = { en, zh };
8
+
9
+ /** The translated string, with {name} placeholders filled from `values`. */
10
+ export function translate(language: Language, key: StringKey, values: Record<string, string | number> = {}): string {
11
+ const text = tables[language][key];
12
+ return text.replace(/\{(\w+)\}/g, (match, name: string) => (name in values ? String(values[name]) : match));
13
+ }
14
+
15
+ /** Words in Latin text, characters in Chinese text: what a writer means by length. */
16
+ export function countWords(text: string): number {
17
+ const han = text.match(/\p{Script=Han}/gu)?.length ?? 0;
18
+ const latin = text
19
+ .replace(/\p{Script=Han}/gu, " ")
20
+ .split(/\s+/)
21
+ .filter(Boolean).length;
22
+ return han + latin;
23
+ }
@@ -0,0 +1,13 @@
1
+ import type { StringKey } from "./en.ts";
2
+
3
+ // 简体中文。键与英文表一一对应;缺一个键就编译不过。
4
+ export const zh: Record<StringKey, string> = {
5
+ app_title: "__NAME_ZH__",
6
+ note_label: "便条",
7
+ note_placeholder: "随便写点什么。关掉窗口也会留着。",
8
+ word_count: "{count} 字",
9
+ saved: "已保存",
10
+ standalone: "正在独立页面上运行:便条只存在这个浏览器里。",
11
+ clear: "清空",
12
+ export_text: "导出为文本",
13
+ };
@@ -0,0 +1,42 @@
1
+ /* The app's own layout. Colours, fonts and controls come from the kit
2
+ (kit.css and the --kit-* tokens the desk sends for its appearance), so the
3
+ window follows Classic, Platinum, Liquid Glass and the rest without an
4
+ appearance selector here. */
5
+
6
+ html,
7
+ body,
8
+ #app {
9
+ height: 100%;
10
+ margin: 0;
11
+ }
12
+
13
+ .app-pane {
14
+ display: grid;
15
+ grid-template-rows: auto 1fr auto;
16
+ gap: 6px;
17
+ height: 100%;
18
+ box-sizing: border-box;
19
+ padding: 10px;
20
+ }
21
+
22
+ .app-label {
23
+ font: var(--kit-control-size, 13px) var(--kit-ui-font, system-ui, sans-serif);
24
+ }
25
+
26
+ .app-note {
27
+ resize: none;
28
+ min-height: 0;
29
+ width: 100%;
30
+ box-sizing: border-box;
31
+ font-family: var(--kit-text-font, Geneva, Verdana, sans-serif);
32
+ }
33
+
34
+ .app-status {
35
+ display: flex;
36
+ justify-content: space-between;
37
+ gap: 12px;
38
+ }
39
+
40
+ .app-status > :first-child {
41
+ white-space: nowrap;
42
+ }
@@ -0,0 +1,34 @@
1
+ import type { Language } from "../strings/index.ts";
2
+ import { countWords, translate } from "../strings/index.ts";
3
+
4
+ interface Props {
5
+ readonly language: Language;
6
+ readonly note: string;
7
+ readonly saved: boolean;
8
+ readonly standalone: boolean;
9
+ readonly onInput: (text: string) => void;
10
+ }
11
+
12
+ /** The whole window: one field and a status bar, drawn with the kit's classes. */
13
+ export function App({ language, note, saved, standalone, onInput }: Props) {
14
+ const t = (key: Parameters<typeof translate>[1], values?: Record<string, string | number>) =>
15
+ translate(language, key, values);
16
+ return (
17
+ <div class="kit-pane app-pane">
18
+ <label class="app-label" for="note">
19
+ {t("note_label")}
20
+ </label>
21
+ <textarea
22
+ id="note"
23
+ class="kit-field app-note"
24
+ value={note}
25
+ placeholder={t("note_placeholder")}
26
+ onInput={(event) => onInput(event.currentTarget.value)}
27
+ />
28
+ <div class="kit-status-bar app-status" aria-live="polite">
29
+ <span>{t("word_count", { count: countWords(note) })}</span>
30
+ <span>{standalone ? t("standalone") : saved ? t("saved") : ""}</span>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, expectTypeOf, it } from "vitest";
2
+ import manifest from "../../public/app.manifest.json";
3
+ import { APP_VERSION } from "../../src/bridge/bridge.ts";
4
+ import { en } from "../../src/strings/en.ts";
5
+ import { countWords, type StringKey, translate } from "../../src/strings/index.ts";
6
+ import { zh } from "../../src/strings/zh.ts";
7
+
8
+ describe("strings", () => {
9
+ it("rejects a misspelled key at compile time", () => {
10
+ expectTypeOf<"app_title">().toMatchTypeOf<StringKey>();
11
+ // @ts-expect-error -- not a key of the English table.
12
+ const misspelled: StringKey = "app_titel";
13
+ expect(misspelled).toBe("app_titel");
14
+ });
15
+
16
+ it("has every English key in Chinese", () => {
17
+ expect(Object.keys(zh).sort()).toEqual(Object.keys(en).sort());
18
+ });
19
+
20
+ it("names the window the way the manifest does, in both languages", () => {
21
+ expect(translate("en", "app_title")).toBe(manifest.strings.en.app_title);
22
+ expect(translate("zh", "app_title")).toBe(manifest.strings.zh.app_title);
23
+ });
24
+
25
+ it("counts Chinese by character and English by word", () => {
26
+ expect(countWords("hello there")).toBe(2);
27
+ expect(countWords("你好 world")).toBe(3);
28
+ expect(translate("en", "word_count", { count: 3 })).toBe("3 words");
29
+ });
30
+ });
31
+
32
+ describe("versions", () => {
33
+ it("connects with the manifest's version, or the desk refuses the hello", () => {
34
+ expect(APP_VERSION).toBe(manifest.version);
35
+ });
36
+ });
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "preact",
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "exactOptionalPropertyTypes": true,
12
+ "noImplicitOverride": true,
13
+ "noEmit": true,
14
+ "allowImportingTsExtensions": true,
15
+ "isolatedModules": true,
16
+ "resolveJsonModule": true,
17
+ "verbatimModuleSyntax": true,
18
+ "skipLibCheck": true,
19
+ "types": ["vite/client"]
20
+ },
21
+ "include": ["src", "tests", "vite.config.ts", "vitest.config.ts"]
22
+ }
@@ -0,0 +1,9 @@
1
+ import preact from "@preact/preset-vite";
2
+ import { defineConfig } from "vite";
3
+
4
+ export default defineConfig({
5
+ // The build is served from /apps/__APP_ID__/ inside the desk and from any folder on its own.
6
+ base: "./",
7
+ plugins: [preact()],
8
+ build: { outDir: "dist", emptyOutDir: true },
9
+ });
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: { include: ["tests/unit/**/*.test.ts"], environment: "node" },
5
+ });