nene-lang 0.1.1 → 0.1.2

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,62 @@
1
+ // 静的生成。ビルドのあとで、アプリを Node の中で動かして、ページごとの HTML を作る
2
+ //
3
+ // "/" から始めて、描いた画面の中のリンク(<a href="/...">)をたどり、たどれたページをすべて作る。
4
+ // /about は dist/about/index.html になる。データから作られるページ(/photos/123 など)のように、
5
+ // リンクでたどれない URL は、これまでどおりブラウザで組み立てる(404.html は中身の空の枠)。
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { runInNewContext } from "node:vm";
9
+ import { bundle, compileProgram } from "./compiler.js";
10
+ const MAX_PAGES = 1000;
11
+ // 作ったページの URL(アプリの中のパス)を返す
12
+ export function prerenderSite(options) {
13
+ const { outDir, base } = options;
14
+ const mount = options.mount ?? "app";
15
+ const shell = readFileSync(join(outDir, "index.html"), "utf8");
16
+ const placeholder = `<div id="${mount}"></div>`;
17
+ if (!shell.includes(placeholder))
18
+ return [];
19
+ const code = bundle(compileProgram(options.entry), {
20
+ mount,
21
+ base: JSON.stringify(base),
22
+ env: JSON.stringify(options.env),
23
+ prerender: true,
24
+ });
25
+ const { rt, program } = runInNewContext(code, { URL, console });
26
+ const pages = [];
27
+ const queue = ["/"];
28
+ const seen = new Set(queue);
29
+ while (queue.length > 0 && pages.length < MAX_PAGES) {
30
+ const path = queue.shift();
31
+ const { html, css } = rt.prerender(program, path);
32
+ const page = shell
33
+ .replace(placeholder, `<div id="${mount}">${html}</div>`)
34
+ .replace("</head>", ` <style data-nene-prerender>${css}</style>\n </head>`);
35
+ const file = join(outDir, ...path.split("/").filter(Boolean).map(decodeURIComponent), "index.html");
36
+ mkdirSync(dirname(file), { recursive: true });
37
+ writeFileSync(file, page);
38
+ pages.push(path);
39
+ // URL で画面を切り替えないアプリは、どのリンクも同じ画面なので "/" だけを作る
40
+ if (!program.onUrlChange)
41
+ break;
42
+ for (const next of links(html, base)) {
43
+ if (seen.has(next))
44
+ continue;
45
+ seen.add(next);
46
+ queue.push(next);
47
+ }
48
+ }
49
+ return pages;
50
+ }
51
+ // HTML の中の、アプリの中へのリンクのパス(置き場所を除き、? と # の後ろも除く)
52
+ function links(html, base) {
53
+ const out = [];
54
+ for (const [, raw] of html.matchAll(/<a\s[^>]*href="([^"]*)"/g)) {
55
+ const href = raw.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
56
+ if (!href.startsWith(base) || href.startsWith("//"))
57
+ continue;
58
+ const path = "/" + href.slice(base.length).split(/[?#]/)[0];
59
+ out.push(path.length > 1 ? path.replace(/\/+$/, "") : path);
60
+ }
61
+ return out;
62
+ }
@@ -0,0 +1,13 @@
1
+ import type { HoverInfo, Location } from "./checker.ts";
2
+ import type { CompiledModule } from "./compiler.ts";
3
+ import type { Pos } from "./syntax.ts";
4
+ export interface Occurrence {
5
+ file: string;
6
+ pos: Pos;
7
+ length: number;
8
+ text: string;
9
+ }
10
+ export declare function definitionOf(h: HoverInfo): Location | undefined;
11
+ export declare function referencesOf(target: Location, modules: CompiledModule[]): Occurrence[];
12
+ export declare function renameProblem(oldName: string, newName: string): string | null;
13
+ export declare function replacementFor(written: string, newName: string): string;
@@ -0,0 +1,48 @@
1
+ // 参照一覧と名前の一括変更。型検査で記録した「名前を使った場所 → 定義の場所」から、
2
+ // 同じ定義を指す場所を、プロジェクトのすべてのモジュールから集める
3
+ export function definitionOf(h) {
4
+ return typeof h.def === "function" ? h.def() : h.def;
5
+ }
6
+ const sameLocation = (a, b) => a.file === b.file && a.pos.line === b.pos.line && a.pos.col === b.pos.col;
7
+ // target を定義とする場所すべて(定義そのものも含む)。同じ場所は 1 つにまとめる
8
+ export function referencesOf(target, modules) {
9
+ const out = new Map();
10
+ const add = (o) => out.set(`${o.file}:${o.pos.line}:${o.pos.col}`, o);
11
+ for (const m of modules) {
12
+ for (const h of m.hovers) {
13
+ const def = definitionOf(h);
14
+ if (!def || !sameLocation(def, target))
15
+ continue;
16
+ // 修飾名(List.map、Icon.close)は、最後の名前だけを指す
17
+ const text = h.name.slice(h.name.lastIndexOf(".") + 1);
18
+ const col = h.pos.col + (h.name.length - text.length);
19
+ add({ file: m.file, pos: { line: h.pos.line, col }, length: text.length, text });
20
+ }
21
+ }
22
+ const head = out.size > 0 ? [...out.values()][0] : null;
23
+ if (head && !out.has(`${target.file}:${target.pos.line}:${target.pos.col}`)) {
24
+ // 定義の場所が使われた場所として記録されていなければ、足す(data のコンストラクタなど)
25
+ add({ file: target.file, pos: target.pos, length: head.text.length, text: head.text });
26
+ }
27
+ return [...out.values()].sort((a, b) => a.file.localeCompare(b.file) || a.pos.line - b.pos.line || a.pos.col - b.pos.col);
28
+ }
29
+ const RESERVED = new Set([
30
+ "module", "where", "import", "qualified", "as", "data", "type", "foreign", "style",
31
+ "if", "then", "else", "case", "of", "let", "in", "do", "class", "instance", "css",
32
+ ]);
33
+ // 新しい名前が、元の名前の種類(小文字始まりの値か、大文字始まりの型・コンストラクタか)として正しいか
34
+ export function renameProblem(oldName, newName) {
35
+ const upper = /^[A-Z]/.test(oldName);
36
+ const ok = upper ? /^[A-Z][\w']*$/.test(newName) : /^[a-z_][\w']*$/.test(newName);
37
+ if (!ok)
38
+ return upper ? "大文字で始まる名前にしてください" : "小文字で始まる名前にしてください";
39
+ if (RESERVED.has(newName))
40
+ return `${newName} は予約された名前なので使えません`;
41
+ return null;
42
+ }
43
+ // 書いてある名前に合わせた新しい名前。部品の関数 button を submitButton にすると、タグ <Button> は <SubmitButton> になる
44
+ export function replacementFor(written, newName) {
45
+ if (/^[A-Z]/.test(written) && /^[a-z]/.test(newName))
46
+ return newName[0].toUpperCase() + newName.slice(1);
47
+ return newName;
48
+ }
package/docs/design.md CHANGED
@@ -96,6 +96,12 @@ Elm でいちばん手間だったのは、JSON のデコーダーを型とは
96
96
 
97
97
  Nene では、JSON との変換を型から決める。メッセージに `GotTodos (Result Http.Error [Todo])` と書けば、`Http.get` はそのとおりに JSON を読む。合わなければ、どこが違ったかを添えて `Err` で返すので、実行時エラーにはならない。変換の規則は 1 つだけにして、フィールド名を付け替えるような設定は持たない。
98
98
 
99
+ ## 部品ごとの状態を持たない
100
+
101
+ React の `useState` のような、部品ごとの状態は入れなかった。入れると、同じ値を Model にも部品にも置けるようになり、「どちらに置くか」で迷う。アプリの状態が Model を見れば全部分かる、という性質もなくなる。
102
+
103
+ Elm で部品の状態がつらいのは、ドロップダウンの開け閉めのような、アプリにとってはどうでもいい状態まで Model に配線することだと思う。そういう見た目だけの状態は、今のブラウザが持てる。`popover` 属性を付けた要素は押せば開き、外を押せば閉じる。`<details>` は開け閉めを覚えている。そこで、アプリが知る必要のある状態は Model に、見た目だけの状態はブラウザに、と分けた。`nene-lang-ui` の Menu・Popover・Accordion・Tooltip は、この形で作ってある。
104
+
99
105
  ## 何を言語に入れ、何を外に出すか
100
106
 
101
107
  ルーティング、HTTP、JSON は標準ライブラリに入れ、差し替えられないようにした。React ではルーターも状態管理もスタイルの当て方も選べるが、そのぶん、プロジェクトごとに書き方が違う。Nene では、こうした土台は一通りだけにする。標準ライブラリに入れても、使っていないモジュールは出力に入らないので、アプリは重くならない。
@@ -104,11 +110,10 @@ Nene では、JSON との変換を型から決める。メッセージに `GotTo
104
110
 
105
111
  ## 道具
106
112
 
107
- `nene` コマンド 1 つで、作成・開発・ビルド・公開前の確認・型検査・エディタ連携まで済む。公開先がサブディレクトリでも、`--base` を付けるだけでコードは書き換えない。エラーは該当する行に印を付けて見せる。設定ファイルは必要なく、入口は `index.html` に書いた `.nn`、画像などは `public/` に置く。特別なことをしたいときだけ、普通の Vite の設定を書ける。
113
+ `nene` コマンド 1 つで、作成・開発・ビルド・公開前の確認・型検査・整形・エディタ連携まで済む。言語サーバーは、定義への移動や補完のほか、参照一覧・名前の一括変更・アウトライン・折りたたみ・コードアクションまで持つ。公開先がサブディレクトリでも、`--base` を付けるだけでコードは書き換えない。エラーは該当する行に印を付けて見せる。`view` は model から画面を作るだけの関数なので、ビルドのときにページを HTML にしておける(静的生成)。設定はいらず、リンクをたどってページを見つける。設定ファイルは必要なく、入口は `index.html` に書いた `.nn`、画像などは `public/` に置く。特別なことをしたいときだけ、普通の Vite の設定を書ける。
108
114
 
109
115
  コマンド・Vite プラグイン・言語サーバーは同じコンパイラを使うので、エディタで出るエラーとビルドで出るエラーは必ず一致する。
110
116
 
111
117
  ## これから
112
118
 
113
- - 部品の中だけで完結する状態(ドロップダウンの開閉など)を、Model に配線せずに持てるようにしたい。純粋さを保ったまま、どう書けるかを考えている。
114
119
  - `style` ブロックの CSS も、エディタで補完や検査ができるようにしたい。
package/docs/guide.md CHANGED
@@ -291,6 +291,13 @@ import Ui.Theme (Tone(..))
291
291
 
292
292
  部品の一覧と、色の変え方は `node_modules/nene-lang-ui/README.md` にある。
293
293
 
294
+ メニューやアコーディオンの開け閉めのような、見た目だけの状態は Model に入れなくてよい。ブラウザが覚えておいてくれる。
295
+
296
+ ```haskell
297
+ <Menu id="sort" label="並び替え" items={sortItems} /> -- 項目を押すと、そのメッセージが届いて閉じる
298
+ <Accordion title="よくある質問">…</Accordion>
299
+ ```
300
+
294
301
  ### ファイルと部品
295
302
 
296
303
  - 1 つのファイルに部品はいくつ書いてもよい。
@@ -400,7 +407,11 @@ main = App.app { init, update, view, onUrlChange = UrlChanged }
400
407
 
401
408
  ### 公開する
402
409
 
403
- `npm run build` で `dist/` に出力したものを、静的ホスティングに置く。URL を直接開いても表示されるように、`404.html` と `_redirects` も一緒に出力される。
410
+ `npm run build` で `dist/` に出力したものを、静的ホスティングに置く。
411
+
412
+ ビルドでは、`/` から始めて画面の中のリンク(`<a href="/...">`)をたどり、たどれたページを HTML にしておく(静的生成)。開いた瞬間に中身が表示され、検索エンジンにも内容が見える。そのあとは今までどおり、ブラウザでアプリとして動く。各ページは、`init` にその URL の `onUrlChange` を当てた画面になる(通信などの `effect` はビルドでは実行しない)。
413
+
414
+ リンクでたどれない URL(データから作られる `/photos/123` など)は、ブラウザで組み立てる。直接開いても表示されるように、`404.html` と `_redirects` も一緒に出力される。
404
415
 
405
416
  `https://<user>.github.io/<repo>/` のようなサブディレクトリに置くときは、`npx nene build --base /<repo>/` でビルドする。コードの中のパス(`<a href="/todos">`、`<img src="/logo.png">`、`Nav.pushUrl "/todos"`)にはその分が自動で付き、`Url.path` からは取り除かれる。コードを書き換える必要はない。
406
417
 
package/docs/spec.md CHANGED
@@ -438,6 +438,28 @@ URL を変えるときは `Nav.pushUrl` を `effect` から返す。
438
438
 
439
439
  テーマ専用の仕組みはない。色は CSS 変数で決め、入口のファイルの `style` ブロックに書く(9.3)。ダークモードも CSS のメディアクエリで書く。
440
440
 
441
+ ### 7.5 状態の置き場所
442
+
443
+ 部品ごとの状態(React の `useState` にあたるもの)はない。状態は次の 2 つに分けて置く。
444
+
445
+ | 状態 | 置き場所 |
446
+ | --- | --- |
447
+ | アプリが知る必要のある状態(入力した値、選んだタブ、ダイアログを開いているか) | Model |
448
+ | 見た目だけの一時的な状態(メニュー・吹き出しの開け閉め、アコーディオン、ツールチップ) | HTML とブラウザ(`popover` 属性、`<details>`、`:hover` など) |
449
+
450
+ - `popover` を付けた要素は、`popovertarget` にその id を書いたボタンで開け閉めでき、外を押すか Esc で閉じる。
451
+ - `open`(`<details>`・`<dialog>`)と `popover` は、ユーザーが開け閉めした状態を、Model から渡す値が変わらない限り保つ。`value` や `checked` は、描くたびに Model の値に合わせる。
452
+ - 見た目だけの状態は、アプリから読めない。読む必要が出てきたら Model に移す。
453
+
454
+ ### 7.6 静的生成
455
+
456
+ `nene build` は、`/` から始めて、描いた画面の中のアプリへのリンク(`<a href>`)をたどり、たどれたページの HTML を出力する(`/about` は `about/index.html`)。
457
+
458
+ - 各ページの model は、`init` にその URL の `onUrlChange` のメッセージを `update` で当てたもの。`effect`・`onStart`・`subscriptions` は実行しない。
459
+ - `onUrlChange` のないアプリは `/` だけを作る。
460
+ - ブラウザでは、読み込んだときに同じ画面を描き直してから動き出す。
461
+ - リンクでたどれない URL は、中身の空の `404.html` から、ブラウザで組み立てる。
462
+
441
463
  ## 8. レイアウトタグ
442
464
 
443
465
  並び方はレイアウトタグで書く。HTML 要素は並び方を持たない(ブロック要素として縦に積まれるだけ)。
@@ -595,10 +617,10 @@ HTML 要素は **意味のため** に使う(見出し、リスト、ボタン
595
617
  | `class` | `Class`(9 章) |
596
618
  | `vars` | フィールドがすべて `String` のレコード(9.4) |
597
619
  | `key` | `String`(リストの要素を見分ける) |
598
- | `id` `title` `role` `lang` `dir` `src` `srcset` `sizes` `alt` `href` `target` `rel` `type` `value` `name` `placeholder` `for` `form` `list` `min` `max` `step` `pattern` `accept` `autocomplete` `width` `height` `loading` `poster` `datetime` `headers` `scope` | `String` |
620
+ | `id` `title` `role` `lang` `dir` `src` `srcset` `sizes` `alt` `href` `target` `rel` `type` `value` `name` `placeholder` `for` `form` `list` `min` `max` `step` `pattern` `accept` `autocomplete` `width` `height` `loading` `poster` `datetime` `headers` `scope` `popovertarget` `popovertargetaction` | `String` |
599
621
  | `aria-*` `data-*`(小文字とハイフン) | `String` |
600
622
  | `tabindex` `rows` `cols` `colspan` `rowspan` `maxlength` `minlength` `span` `start` | `Int` |
601
- | `disabled` `checked` `selected` `readonly` `required` `multiple` `hidden` `autofocus` `open` `controls` `autoplay` `loop` `muted` `playsinline` `reversed` `novalidate` | `Bool`(値を書かずに `hidden` とだけ書くと `True`) |
623
+ | `disabled` `checked` `selected` `readonly` `required` `multiple` `hidden` `autofocus` `open` `controls` `autoplay` `loop` `muted` `playsinline` `reversed` `novalidate` `popover` | `Bool`(値を書かずに `hidden` とだけ書くと `True`) |
602
624
  | `onClick` `onDoubleClick` `onSubmit` `onFocus` `onBlur` `onMouseEnter` `onMouseLeave` | `msg` |
603
625
  | `onInput` `onChange` | `String -> msg`(入力された値、`select` で選ばれた値) |
604
626
  | `onCheck` | `Bool -> msg` |
@@ -3,16 +3,23 @@
3
3
  -- vim.opt.rtp:append("/path/to/Nene/editors/nvim")
4
4
  -- require("nene").setup()
5
5
  --
6
- -- 有効になるもの:
6
+ -- 有効になるもの(キーは nvim の標準のもの):
7
7
  -- - ハイライト(syntax/nene.vim。言語サーバーが動いているときは、その解析結果で色を上書きする)
8
- -- - 言語サーバー: エラー表示、K で型の表示、gd で定義へ移動、補完
8
+ -- - エラー表示、K で型、gd で定義へ、grr で参照一覧、grn で名前の一括変更、gra でコードアクション、
9
+ -- gO でアウトライン、挿入モードの <C-s> で引数のヒント、gq で整形、補完
10
+ -- - 言語サーバーによる折りたたみ(宣言・where・JSX の要素など)
11
+ --
12
+ -- setup の設定:
13
+ -- format_on_save = true 保存するときに整形する(nene fmt と同じ)
14
+ -- folding = false 折りたたみを言語サーバーに任せない
15
+ -- completion = false nvim 標準の補完を使わない(nvim-cmp や blink.cmp があれば、はじめから使わない)
16
+ -- cmd = { ... } 言語サーバーの起動コマンド
9
17
  --
10
- -- 補完は、nvim-cmp や blink.cmp が入っていればそちらに任せ、なければ nvim 標準の補完を使う。
11
18
  -- nvim 0.11 以降が必要。
12
19
  --
13
- -- lazy.nvim なら(GitHub に置いた場合):
20
+ -- lazy.nvim なら:
14
21
  -- {
15
- -- "<ユーザー名>/Nene",
22
+ -- "kimuracoki/nene",
16
23
  -- config = function(plugin)
17
24
  -- vim.opt.rtp:append(plugin.dir .. "/editors/nvim")
18
25
  -- require("nene").setup()
@@ -77,16 +84,33 @@ function M.setup(opts)
77
84
  if builtin == nil then
78
85
  builtin = not external
79
86
  end
80
- if builtin then
81
- vim.api.nvim_create_autocmd("LspAttach", {
82
- callback = function(args)
83
- local client = vim.lsp.get_client_by_id(args.data.client_id)
84
- if client and client.name == "nene" then
85
- vim.lsp.completion.enable(true, client.id, args.buf, { autotrigger = true })
87
+
88
+ vim.api.nvim_create_autocmd("LspAttach", {
89
+ callback = function(args)
90
+ local client = vim.lsp.get_client_by_id(args.data.client_id)
91
+ if not client or client.name ~= "nene" then
92
+ return
93
+ end
94
+ if builtin then
95
+ vim.lsp.completion.enable(true, client.id, args.buf, { autotrigger = true })
96
+ end
97
+ if opts.folding ~= false then
98
+ for _, win in ipairs(vim.fn.win_findbuf(args.buf)) do
99
+ vim.wo[win][0].foldmethod = "expr"
100
+ vim.wo[win][0].foldexpr = "v:lua.vim.lsp.foldexpr()"
101
+ vim.wo[win][0].foldlevel = 99 -- 開いた時点では畳まない
86
102
  end
87
- end,
88
- })
89
- end
103
+ end
104
+ if opts.format_on_save then
105
+ vim.api.nvim_create_autocmd("BufWritePre", {
106
+ buffer = args.buf,
107
+ callback = function()
108
+ vim.lsp.buf.format({ bufnr = args.buf, name = "nene" })
109
+ end,
110
+ })
111
+ end
112
+ end,
113
+ })
90
114
  end
91
115
 
92
116
  return M
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "nene-lang",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "nene": "bin/nene.js"
7
7
  },
8
8
  "scripts": {
9
9
  "dev": "cd examples && node ../src/cli.ts dev",
10
- "build": "cd examples && node ../src/cli.ts build -o ../dist/examples",
10
+ "build": "cd examples && node ../src/cli.ts build --out ../dist/examples",
11
11
  "test": "node --test test/*.test.ts",
12
12
  "typecheck": "tsc",
13
13
  "build:package": "rm -rf build && tsc -p tsconfig.build.json && cp -R src/lib build/lib",
@@ -91,13 +91,17 @@ const $rt = (() => {
91
91
  const layoutStyle = styleElement("layout");
92
92
  const userStyle = styleElement("components");
93
93
  const classes = new Map();
94
+ // ページに入れた CSS の写し。静的生成(prerender)で HTML に埋め込む
95
+ const sheets = { layout: [], components: [] };
94
96
 
95
97
  const addRule = (rule) => {
98
+ sheets.layout.push(rule);
96
99
  if (layoutStyle) layoutStyle.sheet.insertRule(rule, layoutStyle.sheet.cssRules.length);
97
100
  };
98
101
 
99
102
  // style ブロックの CSS(コンパイラがクラス名を部品ごとに書き換えたもの)
100
103
  const css = (text) => {
104
+ sheets.components.push(text);
101
105
  if (userStyle) userStyle.textContent += text + "\n";
102
106
  };
103
107
 
@@ -110,7 +114,6 @@ const $rt = (() => {
110
114
  if (cls) return cls;
111
115
  cls = `nene${classes.size}`;
112
116
  classes.set(key, cls);
113
- if (!hasDocument) return cls;
114
117
  if (base) addRule(`.${cls}{${base}}`);
115
118
  if (hover) addRule(`.${cls}:hover{${hover}}`);
116
119
  if (narrow) addRule(`@media (max-width: ${narrow[0] - 0.02}px){.${cls}{${narrow[1]}}}`);
@@ -179,8 +182,10 @@ const $rt = (() => {
179
182
  onCheck: "change",
180
183
  };
181
184
  // DOM のプロパティとして持つ属性(属性を書き換えるだけでは、画面上の状態が変わらないもの)
182
- const PROPS = { value: "value", checked: "checked", selected: "selected", disabled: "disabled", readonly: "readOnly", open: "open", muted: "muted" };
183
- const BOOLS = new Set(["required", "multiple", "hidden", "autofocus", "controls", "autoplay", "loop", "playsinline", "reversed", "novalidate"]);
185
+ // PROPS は描くたびに model の値へ合わせる。BOOLS は値が変わったときだけ変える。
186
+ // open(details・dialog)と popover は、ユーザーが開け閉めした状態を、model が変わらない限り保つ
187
+ const PROPS = { value: "value", checked: "checked", selected: "selected", disabled: "disabled", readonly: "readOnly", muted: "muted" };
188
+ const BOOLS = new Set(["required", "multiple", "hidden", "autofocus", "controls", "autoplay", "loop", "playsinline", "reversed", "novalidate", "open", "popover"]);
184
189
 
185
190
  // vars={{ barWidth = "40%" }} → --bar-width: 40%
186
191
  const varName = (field) => `--${field.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
@@ -189,6 +194,9 @@ const $rt = (() => {
189
194
  for (const [k, v] of Object.entries(newVars ?? {})) if (!oldVars || oldVars[k] !== v) el.style.setProperty(varName(k), v);
190
195
  };
191
196
 
197
+ // /about や /photo.jpg のような、アプリの一番上から書いたパスには、置き場所(base)を付け足す
198
+ const URL_ATTRS = new Set(["href", "src", "poster"]);
199
+
192
200
  const createRenderer = (dispatch) => {
193
201
  const listen = (el, name) => {
194
202
  el.$nene ??= {};
@@ -204,9 +212,6 @@ const $rt = (() => {
204
212
  });
205
213
  };
206
214
 
207
- // /about や /photo.jpg のような、アプリの一番上から書いたパスには、置き場所(base)を付け足す
208
- const URL_ATTRS = new Set(["href", "src", "poster"]);
209
-
210
215
  const setAttr = (el, name, value, old) => {
211
216
  if (name === "class" || name === "key") return;
212
217
  if (name === "vars") {
@@ -337,8 +342,10 @@ button { cursor: pointer; }
337
342
  const withBase = (url) => (url.startsWith("/") && !url.startsWith("//") ? base + url.slice(1) : url);
338
343
  const inApp = (pathname) => pathname.startsWith(base) || pathname === base.slice(0, -1);
339
344
 
340
- const currentUrl = () => {
341
- const u = new URL(location.href);
345
+ const currentUrl = () => urlOf(location.href);
346
+
347
+ const urlOf = (href) => {
348
+ const u = new URL(href, "http://localhost");
342
349
  return {
343
350
  path: u.pathname.slice(base.length - 1).split("/").filter(Boolean).map(decodeURIComponent),
344
351
  query: [...u.searchParams.entries()],
@@ -374,9 +381,46 @@ button { cursor: pointer; }
374
381
  // Nav.pushUrl などから。url はアプリの中のパス
375
382
  const navigate = (url, replace) => go(withBase(url), replace);
376
383
 
384
+ // ---- 静的生成 ----
385
+ // ビルドのとき(nene build)に Node で呼ばれ、path の画面の HTML と CSS を返す。
386
+ // model は init に、その URL の onUrlChange を当てたもの。effect などの副作用は実行しない
387
+
388
+ const prerender = (config, path) => {
389
+ let model = config.init;
390
+ if (config.onUrlChange) model = config.update(config.onUrlChange(urlOf(path)))(model);
391
+ const html = norm([config.view(model)]).map(toHtml).join("");
392
+ const css = [BASE_CSS, ...sheets.layout, ...sheets.components].join("\n");
393
+ return { html, css };
394
+ };
395
+
396
+ const VOID = new Set(["img", "input", "br", "hr", "source", "col", "wbr"]);
397
+ const escape = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
398
+
399
+ const toHtml = (v) => {
400
+ if (v.t === "text") return escape(v.text);
401
+ const attrs = [];
402
+ if (v.cls) attrs.push(`class="${escape(v.cls)}"`);
403
+ for (const [k, val] of Object.entries(v.attrs)) {
404
+ if (k === "class" || k === "key" || k in EVENTS) continue;
405
+ if (k === "vars") {
406
+ const decls = Object.entries(val).map(([f, x]) => `${varName(f)}: ${x}`).join("; ");
407
+ attrs.push(`style="${escape(decls)}"`);
408
+ } else if (typeof val === "boolean") {
409
+ if (val) attrs.push(k);
410
+ } else {
411
+ attrs.push(`${k}="${escape(URL_ATTRS.has(k) ? withBase(val) : val)}"`);
412
+ }
413
+ }
414
+ const open = `<${v.tag}${attrs.length ? " " + attrs.join(" ") : ""}>`;
415
+ if (VOID.has(v.tag)) return open;
416
+ return `${open}${v.children.map(toHtml).join("")}</${v.tag}>`;
417
+ };
418
+
377
419
  // ---- アプリの実行 ----
378
420
 
379
421
  const run = (config, root) => {
422
+ // 静的生成で埋め込んだ CSS は、ランタイムが同じものを入れたので外す(描き直すのと同じ流れで、見た目は変わらない)
423
+ for (const el of document.querySelectorAll("style[data-nene-prerender]")) el.remove();
380
424
 
381
425
  let model = config.init;
382
426
  let pending = false;
@@ -457,7 +501,7 @@ button { cursor: pointer; }
457
501
 
458
502
  return {
459
503
  lazy, eq, append, compose, idiv, show, fail, Just, Nothing, Ok, Err, defaults,
460
- h, frag, layout, spacer, run, sub, navigate, css, scope, configure, envValue,
504
+ h, frag, layout, spacer, run, prerender, sub, navigate, css, scope, configure, envValue,
461
505
  // テスト用
462
506
  norm,
463
507
  };
@@ -5,6 +5,7 @@
5
5
  ## コマンド
6
6
 
7
7
  - `npm run check` … 型検査。`.nn` を編集すると自動で実行され、エラーがあれば伝えられる(`.claude/hooks/check.mjs`)
8
+ - `npm run fmt` … 整形(`=` の位置などをそろえる)。書き終えたらかける
8
9
  - `npm run dev` … 開発サーバー
9
10
  - `npm run build` … `dist/` に出力
10
11
 
@@ -44,6 +45,7 @@ import Ui.Theme (Tone(..))
44
45
 
45
46
  - 色の種類 `tone` はどの部品も `Primary` `Neutral` `Success` `Warning` `Danger`、大きさ `size` は `S` `M` `L`。
46
47
  - 部品の見た目は外から `class` で変えられない。色は入口の `style` ブロックで `:root { --primary: …; }` のように CSS 変数を変える。
48
+ - 状態の置き場所: アプリが知る必要のある状態は Model。メニュー・吹き出し・アコーディオンの開け閉めのような見た目だけの状態は Model に入れず、`Menu` `Popover` `Accordion` `Tooltip`(ブラウザが開け閉めを持つ)を使う。部品ごとの状態(useState)は Nene にはない。
47
49
  - where の名前がタグとぶつかると、その名前が部品として使われる(`select = …` と書くと `<Select>` が使えない)。
48
50
 
49
51
  ## アプリの形
@@ -6,6 +6,7 @@
6
6
  npm install
7
7
  npm run dev # 開発サーバー(保存すると自動で再読み込み)
8
8
  npm run check # 型検査だけ
9
+ npm run fmt # 整形
9
10
  npm run build # dist/ に公開用のファイルを出力
10
11
  npm run preview # dist/ を手元で動かして確かめる
11
12
  ```
@@ -22,12 +23,12 @@ npm run preview # dist/ を手元で動かして確かめる
22
23
 
23
24
  ## 公開する
24
25
 
25
- `npm run build` の出力(`dist/`)を、静的ホスティングにそのまま置く。URL を直接開いたときやリロードしたときも表示されるように、`404.html` と `_redirects` も一緒に出力される。
26
+ `npm run build` の出力(`dist/`)を、静的ホスティングにそのまま置く。リンクでたどれるページは HTML にしてある(開いた瞬間に表示され、検索エンジンにも見える)。たどれない URL を直接開いたときやリロードしたときも表示されるように、`404.html` と `_redirects` も一緒に出力される。
26
27
 
27
28
  | 置き場所 | やること |
28
29
  | --- | --- |
29
30
  | Netlify・Cloudflare Pages | ビルドコマンドに `npm run build`、出力先に `dist` を指定する |
30
- | Vercel | 上と同じ。加えて `vercel.json` に `{ "rewrites": [{ "source": "/(.*)", "destination": "/" }] }` を書く |
31
+ | Vercel | 上と同じ。加えて `vercel.json` に `{ "rewrites": [{ "source": "/(.*)", "destination": "/404.html" }] }` を書く |
31
32
  | GitHub Pages(`https://<user>.github.io/<repo>/`) | `npx nene build --base /<repo>/` でビルドして、`dist/` を公開する |
32
33
 
33
34
  `--base` を付けると、コードの中で `/about` や `/logo.png` と書いたパスに置き場所が自動で付く。コードを書き換える必要はない。
@@ -6,7 +6,8 @@
6
6
  "dev": "nene dev",
7
7
  "build": "nene build",
8
8
  "preview": "nene preview",
9
- "check": "nene check"
9
+ "check": "nene check",
10
+ "fmt": "nene fmt src"
10
11
  },
11
12
  "dependencies": {
12
13
  "nene-lang-ui": "{{uiDependency}}"