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.
- package/README.md +24 -8
- package/build/actions.d.ts +22 -0
- package/build/actions.js +124 -0
- package/build/checker.js +2 -2
- package/build/cli.js +45 -5
- package/build/compiler.d.ts +2 -0
- package/build/compiler.js +7 -1
- package/build/format.d.ts +1 -0
- package/build/format.js +120 -0
- package/build/lsp.js +263 -3
- package/build/outline.d.ts +49 -0
- package/build/outline.js +298 -0
- package/build/prerender.d.ts +8 -0
- package/build/prerender.js +62 -0
- package/build/references.d.ts +13 -0
- package/build/references.js +48 -0
- package/docs/design.md +7 -2
- package/docs/guide.md +12 -1
- package/docs/spec.md +24 -2
- package/editors/nvim/lua/nene.lua +38 -14
- package/package.json +2 -2
- package/runtime/runtime.js +53 -9
- package/template/CLAUDE.md +2 -0
- package/template/README.md +3 -2
- package/template/package.json +2 -1
|
@@ -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(/&/g, "&").replace(/"/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
|
|
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/` に出力したものを、静的ホスティングに置く。
|
|
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
|
-
-- -
|
|
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
|
|
20
|
+
-- lazy.nvim なら:
|
|
14
21
|
-- {
|
|
15
|
-
-- "
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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",
|
package/runtime/runtime.js
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
};
|
package/template/CLAUDE.md
CHANGED
|
@@ -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
|
## アプリの形
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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` と書いたパスに置き場所が自動で付く。コードを書き換える必要はない。
|