isola 0.1.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae141ebf3510995229e7bc854c3c43a566deb7f74b47f5c28c64840c1f25db18
4
- data.tar.gz: 458948286fb8f457a77e5a7d9e9ed7805258ea53a9c6cb5ee3e4640a35d9f41e
3
+ metadata.gz: 507fdf7f262bfde7e67d143cc6d5b09b0c7b7939dfbc1c7aaf6dbcc9f1a5cf38
4
+ data.tar.gz: f39703fba0476b6cd38f5877111d2e3a9abf06cffe6cdc73341487085ff631fc
5
5
  SHA512:
6
- metadata.gz: 7909154b135a3667a4289d5e9e91ddcf3e79029801e17b103ddebe906789561ea6a11499de3cb7e090bb83e38339c166786f8b05f1053a536464f3ec6f6843fa
7
- data.tar.gz: '083de5dffb0186058bd30f4edf7d41e7b9a3472ea09b5b661654a4456585b74062cbf5ab11069af43c4994cf16e80626b5b5a90834effa2f59854c6091e0b246'
6
+ metadata.gz: 1f85fd79af5a9bea7886312fede020b672514f111be326758b6c36797bc8c449a37d294a6db5d2c38215be855447e4a9d56135526bcafe767c0de6d45280a3f2
7
+ data.tar.gz: ee38afab7cd7bf59701503651928773202de085f6e2d84be8e081429c4692ca9da069764ab1c36c9399006591f535c64a0653371d8a77abb6616bcf1267e0a0e
data/.rubocop.yml ADDED
@@ -0,0 +1,2 @@
1
+ inherit_gem:
2
+ standard: config/base.yml
data/USAGE.ja.md CHANGED
@@ -116,3 +116,67 @@ Isolaが処理するテンプレートエンジンは現在 **ERB**(`.erb`)
116
116
  | `*.css`, `*.js` など | なし | そのままコピー |
117
117
 
118
118
  `_`や`.`で始まるファイル・ディレクトリは自動的に除外されます(`_layouts/`と`_includes/`を除く)。
119
+
120
+ ## 多言語対応
121
+
122
+ Isolaは多言語サイトの構築をサポートしています。デフォルト言語のページはサイトルートに、その他の言語は言語コード名のサブディレクトリに配置します。
123
+
124
+ ### 設定
125
+
126
+ `_config.yaml`に`default_language`と`languages`を追加します:
127
+
128
+ ```yaml
129
+ default_language: ja
130
+ languages:
131
+ ja:
132
+ label: "日本語"
133
+ en:
134
+ label: "English"
135
+ title: "My Site (EN)"
136
+ ```
137
+
138
+ 各言語エントリでサイトレベルの設定値を上書きできます。例えば上記の設定では、英語ページのレンダリング時に`site[:title]`が`"My Site (EN)"`を返します。
139
+
140
+ ### ディレクトリ構成
141
+
142
+ ```
143
+ my-site/
144
+ ├── _config.yaml
145
+ ├── _layouts/
146
+ │ ├── default.html.erb # 共有レイアウト(全言語で使用)
147
+ │ └── en/default.html.erb # 英語専用レイアウト(上書き)
148
+ ├── _includes/
149
+ │ ├── head.html.erb # 共有インクルード
150
+ │ └── en/head.html.erb # 英語専用インクルード(上書き)
151
+ ├── index.md # デフォルト言語(ja)のページ
152
+ └── en/index.md # 英語ページ
153
+ ```
154
+
155
+ - **ページ**: デフォルト言語のページはルートに配置します。他の言語は`<lang>/`サブディレクトリに配置します(例: `en/index.md`)。
156
+ - **レイアウトとインクルード**: 言語固有のオーバーライドは`_layouts/<lang>/`や`_includes/<lang>/`に配置します。言語固有のバージョンが見つからない場合、共有バージョンがフォールバックとして使われます。
157
+
158
+ ### テンプレート変数
159
+
160
+ 標準のテンプレート変数に加え、多言語サイトでは以下が利用できます:
161
+
162
+ - `page[:lang]` — 現在のページの言語(例: `:ja`, `:en`)
163
+ - `page[:translations]` — 現在のページの全翻訳版を`{lang: url_path}`形式のハッシュで返す(先頭が`/`のURLパスであり、`href`属性などにそのまま利用できます)
164
+
165
+ #### hreflangリンクの生成
166
+
167
+ `page[:translations]`を使って代替言語リンクを出力できます:
168
+
169
+ ```erb
170
+ <% page[:translations].each do |lang, url| %>
171
+ <link rel="alternate" hreflang="<%= lang %>" href="<%= url %>">
172
+ <% end %>
173
+ ```
174
+
175
+ ### 出力
176
+
177
+ 出力はソースの構成を反映します:
178
+
179
+ | ソース | 出力 |
180
+ |---|---|
181
+ | `index.md` | `_site/index.html` |
182
+ | `en/index.md` | `_site/en/index.html` |
data/USAGE.md CHANGED
@@ -116,3 +116,67 @@ If an extension remains after processing, it is kept as-is. If no extension rema
116
116
  | `*.css`, `*.js` etc. | None | Copied as-is |
117
117
 
118
118
  Files and directories starting with `_` or `.` are excluded automatically (except `_layouts/` and `_includes/`).
119
+
120
+ ## Multi-Language Support
121
+
122
+ Isola supports building multi-language sites. Pages for the default language live at the site root, while other languages are placed in subdirectories named by language code.
123
+
124
+ ### Configuration
125
+
126
+ Add `default_language` and `languages` to `_config.yaml`:
127
+
128
+ ```yaml
129
+ default_language: ja
130
+ languages:
131
+ ja:
132
+ label: "日本語"
133
+ en:
134
+ label: "English"
135
+ title: "My Site (EN)"
136
+ ```
137
+
138
+ Each language entry can override any site-level configuration value. For example, `site[:title]` returns `"My Site (EN)"` when rendering English pages.
139
+
140
+ ### Directory Structure
141
+
142
+ ```
143
+ my-site/
144
+ ├── _config.yaml
145
+ ├── _layouts/
146
+ │ ├── default.html.erb # Shared layout (used by all languages)
147
+ │ └── en/default.html.erb # English-specific layout override
148
+ ├── _includes/
149
+ │ ├── head.html.erb # Shared include
150
+ │ └── en/head.html.erb # English-specific include override
151
+ ├── index.md # Default language (ja) page
152
+ └── en/index.md # English page
153
+ ```
154
+
155
+ - **Pages**: Default-language pages live at the root. Other languages go in `<lang>/` subdirectories (e.g. `en/index.md`).
156
+ - **Layouts and includes**: Place language-specific overrides under `_layouts/<lang>/` or `_includes/<lang>/`. If a language-specific version is not found, the shared version is used as a fallback.
157
+
158
+ ### Template Variables
159
+
160
+ In addition to the standard template variables, multi-language sites provide:
161
+
162
+ - `page[:lang]` — the language of the current page (e.g. `:ja`, `:en`)
163
+ - `page[:translations]` — a hash of `{lang: url_path}` (URL paths starting with `/`, suitable for `href` attributes) for all available translations of the current page
164
+
165
+ #### Generating hreflang Links
166
+
167
+ Use `page[:translations]` to output alternate-language links:
168
+
169
+ ```erb
170
+ <% page[:translations].each do |lang, url| %>
171
+ <link rel="alternate" hreflang="<%= lang %>" href="<%= url %>">
172
+ <% end %>
173
+ ```
174
+
175
+ ### Output
176
+
177
+ The output mirrors the source structure:
178
+
179
+ | Source | Output |
180
+ |---|---|
181
+ | `index.md` | `_site/index.html` |
182
+ | `en/index.md` | `_site/en/index.html` |
data/lib/isola/context.rb CHANGED
@@ -2,30 +2,41 @@ require "tilt"
2
2
 
3
3
  module Isola
4
4
  class Context
5
- attr_reader :site, :content, :layout
6
- def initialize(page, site)
7
- @page_source = page
8
- @meta = {lang: site[:lang]}.merge(page.meta).freeze
5
+ attr_reader :content, :layout
6
+ def initialize(source, site, languages: {})
7
+ @source = source
8
+ @meta = source.meta.freeze
9
9
  @site = site
10
10
  @content = ""
11
11
  @layout = {}
12
+
13
+ site_config = @site.config.merge(languages[@source[:lang]] || {})
14
+ @site_proxy = SimpleDelegator.new(@site).tap do
15
+ it.define_singleton_method(:[]) do |key|
16
+ site_config[key]
17
+ end
18
+ end
12
19
  end
13
20
 
14
21
  def page
15
22
  @meta
16
23
  end
17
24
 
25
+ def site
26
+ @site_proxy
27
+ end
28
+
18
29
  def include name, params = {}
19
- i = @site.include name
30
+ i = @site.include name, lang: @source[:lang]
20
31
  raise "include #{name} not found in #{@current.filepath}" unless i
21
32
  i.render(self, @site, params)[0]
22
33
  end
23
34
 
24
35
  def render
25
- @current = @page_source
26
- @content, path = @page_source.render(self, @site)
36
+ @current = @source
37
+ @content, path = @source.render(self, @site)
27
38
  while @current.meta[:layout]
28
- layout = site.layout(@current.meta[:layout])
39
+ layout = site.layout(@current.meta[:layout], lang: @source[:lang])
29
40
  raise "#{@current.meta[:layout]} not found for #{@current.filepath}" unless layout
30
41
  @current = layout
31
42
  @content, _ = @current.render(self, @site)
@@ -1,19 +1,20 @@
1
1
  module Isola
2
2
  class FileHandler
3
- attr_reader :pages, :layouts, :includes, :root_dir
3
+ attr_reader :entries, :layouts, :includes, :root_dir
4
4
  DEFAULT_EXCLUDES = [
5
5
  ".sass-cache", "gemfiles",
6
6
  "Gemfile", "Gemfile.lock", "node_modules",
7
7
  "vendor/bundle/", "vendor/cache/",
8
8
  "vendor/gems/", "vendor/ruby/"
9
9
  ]
10
- def initialize(root_dir, excludes: [])
10
+ def initialize(root_dir, output_path_func: nil, excludes: [])
11
11
  @excludes = DEFAULT_EXCLUDES.union(excludes)
12
12
  @rejects = Regexp.union(%r{(?:^|/)[._]}, %r{~$})
13
13
  @root_dir = File.absolute_path(root_dir)
14
- @pages = {}
14
+ @entries = {}
15
15
  @layouts = {}
16
16
  @includes = {}
17
+ @output_path_func = output_path_func || method(:remove_exts)
17
18
  collect(@root_dir)
18
19
  end
19
20
 
@@ -36,7 +37,7 @@ module Isola
36
37
  elsif path.start_with?("_includes/")
37
38
  @includes[remove_exts(path).delete_prefix("_includes/")] = path
38
39
  else
39
- @pages[remove_exts(path)] = path
40
+ @entries[@output_path_func.call(path)] = path
40
41
  end
41
42
  end
42
43
  end
@@ -0,0 +1,39 @@
1
+ module Isola
2
+ class LanguagePathRouter
3
+ def initialize(default_language:, languages:)
4
+ @default_language = default_language.to_s
5
+ @languages = languages.map(&:to_s)
6
+ end
7
+
8
+ def language_for(path)
9
+ p = Pathname(path).each_filename.to_a
10
+ if p.length > 1 && @languages.include?(p[0])
11
+ p[0].to_sym
12
+ else
13
+ @default_language.to_sym
14
+ end
15
+ end
16
+
17
+ def canonical_path(path)
18
+ p = Pathname(path).each_filename.to_a
19
+ if p.length > 1 && @languages.include?(p[0])
20
+ File.join(p[1..])
21
+ else
22
+ path
23
+ end
24
+ end
25
+
26
+ def localized_path(path, language)
27
+ canonical = canonical_path(path)
28
+ if language.to_s == @default_language
29
+ canonical
30
+ else
31
+ File.join(language.to_s, canonical)
32
+ end
33
+ end
34
+
35
+ def candidate_paths(path)
36
+ @languages.to_h { |lang| [lang.to_sym, localized_path(path, lang)] }
37
+ end
38
+ end
39
+ end
data/lib/isola/site.rb CHANGED
@@ -1,48 +1,67 @@
1
1
  require "yaml"
2
2
  require "fileutils"
3
+ require "delegate"
4
+
3
5
  module Isola
4
6
  class Site
5
7
  attr_accessor :config
6
8
  DEFAULT_CONFIG = {url: "http://example.com",
7
9
  title: "my awesome site",
8
10
  destination: "_site",
9
- default_language: "en",
11
+ default_language: :en,
10
12
  host: "127.0.0.1",
13
+ languages: {},
11
14
  port: 4444}.freeze
12
- SUPPORTED_TILT_EXT = [".erb", ".md", ".markdown", ".mkd"]
13
- EXT_MAP = {".md" => ".html", ".mkd" => ".html", ".markdown" => ".html", "" => ".html"}
15
+ SUPPORTED_TILT_EXT = [".erb", ".md", ".markdown", ".mkd", ".html"]
16
+ EXT_MAP = {".md" => ".html", ".mkd" => ".html", ".markdown" => ".html", ".html" => ".html", "" => ".html"}
14
17
  def initialize(config)
15
18
  @config = DEFAULT_CONFIG.merge(YAML.safe_load(config, symbolize_names: true) || {})
19
+ @config[:default_language] = @config[:default_language].to_sym
16
20
  @config[:root_dir] ||= Dir.pwd
17
21
  @config[:excludes] ||= []
22
+ @lang_router = LanguagePathRouter.new(
23
+ default_language: default_language,
24
+ languages: languages.keys
25
+ )
18
26
  collect_files
19
27
  end
20
28
 
21
29
  def [] key
22
- if key == :lang
23
- key = :default_language
24
- end
25
30
  @config[key]
26
31
  end
27
32
 
28
- def supported_ext? ext
33
+ def default_language
34
+ @config[:default_language]
35
+ end
36
+
37
+ def languages
38
+ @config[:languages]
39
+ end
40
+
41
+ def ext_to_process_with_tilt? ext
29
42
  SUPPORTED_TILT_EXT.include? ext
30
43
  end
31
44
 
32
- def result_ext_for ext
33
- EXT_MAP[ext]
45
+ def process_extensions(path)
46
+ path = path.dup
47
+ last_ext = nil
48
+ while ext_to_process_with_tilt?(ext = File.extname(path))
49
+ yield(path, ext) if block_given?
50
+ path.delete_suffix!(ext)
51
+ last_ext = ext
52
+ end
53
+ ext.empty? ? path + result_ext_for(last_ext) : path
54
+ end
55
+
56
+ def output_path_for path
57
+ process_extensions path
34
58
  end
35
59
 
36
60
  def build
37
- dest_dir = File.join(@file_handler.root_dir, @config[:destination])
38
61
  FileUtils.rm_rf(dest_dir)
39
- @file_handler.pages.each do |name, path|
40
- page = Source.new(path, read_in_site(path))
41
- puts "building #{path}..."
42
- rendered, path = Context.new(page, self).render
43
- dest_path = File.join(dest_dir, path)
44
- FileUtils.mkdir_p(File.dirname(dest_path))
45
- File.write(dest_path, rendered)
62
+ entries.each do |name, entry|
63
+ puts "building #{name}..."
64
+ render_to_dest entry
46
65
  end
47
66
  puts "done."
48
67
  end
@@ -52,35 +71,100 @@ module Isola
52
71
  build
53
72
  end
54
73
 
74
+ def url_path_for(path)
75
+ # will support base_url for the future.
76
+ File.join("/", path)
77
+ end
78
+
55
79
  def ignore?(path)
56
80
  @file_handler.ignore?(path)
57
81
  end
58
82
 
59
- def layout name
60
- find_source(name, @parsed_layouts, @file_handler.layouts)
83
+ def layout name, lang: nil
84
+ find_entry(name, @parsed_layouts, @file_handler.layouts, lang: lang)
61
85
  end
62
86
 
63
- def include name
64
- find_source(name, @parsed_includes, @file_handler.includes)
87
+ def include name, lang: nil
88
+ find_entry(name, @parsed_includes, @file_handler.includes, lang: lang)
89
+ end
90
+
91
+ def entry name
92
+ find_entry(name, @parsed_entries, @file_handler.entries)
93
+ end
94
+
95
+ def entries
96
+ Enumerator.new do |yielder|
97
+ @file_handler.entries.each_key do |name|
98
+ yielder.yield name, entry(name)
99
+ end
100
+ end
65
101
  end
66
102
 
67
103
  private
68
104
 
105
+ def dest_dir
106
+ File.join(@file_handler.root_dir, @config[:destination])
107
+ end
108
+
109
+ def render_to_dest entry
110
+ if entry.instance_of? Source
111
+ rendered, path = Context.new(entry, self, languages: languages).render
112
+ dest_path = File.join(dest_dir, path)
113
+ FileUtils.mkdir_p(File.dirname(dest_path))
114
+ File.write(dest_path, rendered)
115
+ elsif entry.instance_of? StaticFile
116
+ path = entry.path
117
+ src_path = File.join(config[:root_dir], path)
118
+ dest_path = File.join(dest_dir, path)
119
+ FileUtils.mkdir_p(File.dirname(dest_path))
120
+ FileUtils.copy(src_path, dest_path)
121
+ else
122
+ raise "can't render class #{entry.class}"
123
+ end
124
+ end
125
+
126
+ def result_ext_for ext
127
+ return "" if ext.nil?
128
+ EXT_MAP[ext]
129
+ end
130
+
69
131
  def collect_files
70
- @file_handler = FileHandler.new(config[:root_dir], excludes: @config[:excludes])
132
+ @file_handler = FileHandler.new(config[:root_dir], output_path_func: method(:output_path_for), excludes: @config[:excludes])
71
133
  @parsed_layouts = {}
72
134
  @parsed_includes = {}
135
+ @parsed_entries = {}
73
136
  end
74
137
 
75
- def find_source(name, cache, store)
76
- cache[name] ||=
138
+ def find_entry(name, cache, store, lang: nil)
139
+ resolved = resolve_localized(name, store, lang)
140
+ lang = @lang_router.language_for(resolved)
141
+ cache[resolved] ||=
77
142
  begin
78
- p = store[name]
143
+ p = store[resolved]
79
144
  return nil unless p
80
- Source.new(p, read_in_site(p))
145
+ if ext_to_process_with_tilt?(File.extname(p))
146
+ translations = translations_for(resolved, store)
147
+ Source.new(p, read_in_site(p), lang: lang, translations: translations)
148
+ else
149
+ StaticFile.new(p)
150
+ end
81
151
  end
82
152
  end
83
153
 
154
+ def resolve_localized(name, store, lang)
155
+ return name unless lang && @lang_router.language_for(name) != lang
156
+ localized = @lang_router.localized_path(name, lang)
157
+ store[localized] ? localized : name
158
+ end
159
+
160
+ def translations_for(path, store)
161
+ @lang_router.candidate_paths(path).select do |_, candidate|
162
+ store.key?(candidate)
163
+ end.transform_values do |candidate|
164
+ url_path_for(candidate)
165
+ end
166
+ end
167
+
84
168
  def read_in_site(p)
85
169
  File.read(File.join(config[:root_dir], p))
86
170
  end
data/lib/isola/source.rb CHANGED
@@ -3,13 +3,15 @@ require "yaml"
3
3
  module Isola
4
4
  class Source
5
5
  attr_reader :filepath, :meta, :content
6
- def initialize filepath, text
6
+ def initialize filepath, text, lang:, translations: {}
7
7
  @filepath = filepath
8
8
  @meta, @content = if (m = text.match(/\A---\s*\n(.+?)^---\s*\n(.*)\z/m))
9
- [YAML.safe_load(m[1], symbolize_names: true), m[2]]
9
+ [YAML.safe_load(m[1], symbolize_names: true) || {}, m[2]]
10
10
  else
11
11
  [{}, text]
12
12
  end
13
+ @meta[:lang] = lang
14
+ @meta[:translations] = translations
13
15
  end
14
16
 
15
17
  def [] key
@@ -17,20 +19,11 @@ module Isola
17
19
  end
18
20
 
19
21
  def render(context, site, params = {})
20
- path = @filepath.dup
21
22
  rendered = @content.dup
22
- last_ext = ""
23
- while !(ext = File.extname(path)).empty? && site.supported_ext?(ext)
24
- rendered = Tilt.new(path) { rendered }.render(context, params)
25
- path.delete_suffix! ext
26
- last_ext = ext
27
- end
28
-
29
- if ext.empty?
30
- [rendered, path + site.result_ext_for(last_ext)]
31
- else
32
- [rendered, path]
23
+ output_path = site.process_extensions(@filepath) do |current_path, _ext|
24
+ rendered = Tilt.new(current_path) { rendered }.render(context, params)
33
25
  end
26
+ [rendered, output_path]
34
27
  end
35
28
  end
36
29
  end
@@ -0,0 +1,8 @@
1
+ module Isola
2
+ class StaticFile
3
+ attr_reader :path
4
+ def initialize(path)
5
+ @path = path
6
+ end
7
+ end
8
+ end
data/lib/isola/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Isola
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/isola.rb CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  require_relative "isola/version"
4
4
  require_relative "isola/site"
5
+ require_relative "isola/language_path_router"
5
6
  require_relative "isola/file_handler"
6
7
  require_relative "isola/source"
8
+ require_relative "isola/static_file"
7
9
  require_relative "isola/context"
8
10
  require_relative "isola/watcher"
9
11
  require_relative "isola/dev_server"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: isola
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Satoshi Kojima
@@ -87,6 +87,7 @@ executables:
87
87
  extensions: []
88
88
  extra_rdoc_files: []
89
89
  files:
90
+ - ".rubocop.yml"
90
91
  - LICENSE
91
92
  - README.md
92
93
  - Rakefile
@@ -97,8 +98,10 @@ files:
97
98
  - lib/isola/context.rb
98
99
  - lib/isola/dev_server.rb
99
100
  - lib/isola/file_handler.rb
101
+ - lib/isola/language_path_router.rb
100
102
  - lib/isola/site.rb
101
103
  - lib/isola/source.rb
104
+ - lib/isola/static_file.rb
102
105
  - lib/isola/version.rb
103
106
  - lib/isola/watcher.rb
104
107
  homepage: https://github.com/skoji/isola