() translation by (you can also view the original English article)
Setelah membuat struktur dasar sistem manajemen konten (CMS), dan server sebenarnya menggunakan Go dan Node.js, Anda siap untuk mencoba dengan bahasa lainnya.
Kali ini, saya menggunakan bahasa Ruby untuk membuat server-nya. Saya telah menemukan bahwa dengan membuat program yang sama dalam banyak bahasa, Anda mulai mendapatkan wawasan baru tentang cara yang lebih baik untuk menerapkan program ini. Anda juga melihat lebih banyak cara untuk menambahkan fungsionalitas ke program. Mari kita mulai.
Pengaturan dan Memuat Perpustakaannya
Untuk memprogram di Ruby, Anda perlu instalasi versi terbaru pada sistem Anda. Banyak sistem operasi yang telah diinstal sebelumnya dengan Ruby akhir-akhir ini (Linux dan OS X), namun biasanya memiliki versi yang lebih lawas. Tutorial ini mengasumsikan bahwa Anda memiliki versi Ruby 2.4.
Cara termudah untuk upgrade ke ruby versi terbaru adalah dengan menggunakan RVM. Untuk menginstal RVM di Linux atau Mac OS X, ketik berikut ini di terminal:
1 |
gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 |
2 |
curl -sSL https://get.rvm.io | bash -s stable |
Ini akan membuat koneksi yang aman untuk mengunduh dan menginstal RVM. Ini akan menginstal rilis Ruby terbaru yang stabil juga. Anda harus memuat ulang shell Anda untuk menyelesaikan instalasi.
Untuk Windows, Anda bisa mendownload Windows Ruby Installer. Saat ini, paket ini sampai ke Ruby 2.2.2, yang mana bagus untuk menjalankan perpustakaan dan skrip dalam tutorial ini.
Setelah bahasa Ruby terpasang dengan benar, Anda sekarang dapat menginstal perpustakaannya. Ruby, sama seperti Go dan Node, memiliki manajer paket untuk menginstal perpustakaan pihak ketiga. Di terminal, ketik berikut ini:
1 |
gem install sinatra
|
2 |
gem install ruby-handlebars
|
3 |
gem install kramdown
|
4 |
gem install slim
|
Ini menginstal perpustakaan Sinatra, Ruby Handlebars, Kramdown, dan Slim. Sinatra adalah framework aplikasi web. Ruby Handlebars menerapkan mesin template Handlebars di Ruby. Kramdown adalah konverter Markdown ke HTML. Slim adalah perpustakaan bekerja mirip Jade, namun tidak mencakup definisi makro Jade. Oleh karena itu, makro yang digunakan dalam indeks posting Berita dan Blog sekarang adalah Jade normal.
Membuat File rubyPress.rb
Di direktori teratas, buat file rubyPress.rb
dan tambahkan kode berikut. Saya akan berkomentar tentang setiap bagian saat ditambahkan ke file.
1 |
#
|
2 |
# Load the Libraries.
|
3 |
#
|
4 |
require 'sinatra' # http://www.sinatrarb.com/ |
5 |
require 'ruby-handlebars' # https://github.com/vincent-psarga/ruby-handlebars |
6 |
require 'kramdown' # http://kramdown.gettalong.org |
7 |
require 'slim' # http://slim-lang.com/ |
8 |
require 'json' |
9 |
require 'date' |
Hal pertama yang harus dilakukan adalah memuat perpustakaannya. Berbeda dengan Node.js, ini tidak dimasukkan ke dalam variabel. Perpustakaan Ruby menambahkan fungsinya ke lingkup program.
1 |
#
|
2 |
# Setup the Handlebars engine.
|
3 |
#
|
4 |
$hbs = Handlebars::Handlebars.new |
5 |
|
6 |
#
|
7 |
# HandleBars Helper: date
|
8 |
#
|
9 |
# Description: This helper returns the current date
|
10 |
# based on the format given.
|
11 |
#
|
12 |
$hbs.register_helper('date') {|context, format| |
13 |
now = Date.today |
14 |
now.strftime(format) |
15 |
}
|
16 |
|
17 |
#
|
18 |
# HandleBars Helper: cdate
|
19 |
#
|
20 |
# Description: This helper returns the given date
|
21 |
# based on the format given.
|
22 |
#
|
23 |
$hbs.register_helper('cdate') {|context, date, format| |
24 |
day = Date.parse(date) |
25 |
day.strftime(format) |
26 |
}
|
27 |
|
28 |
#
|
29 |
# HandleBars Helper: save
|
30 |
#
|
31 |
# Description: This helper expects a
|
32 |
# "|" where the name
|
33 |
# is saved with the value for future
|
34 |
# expansions. It also returns the
|
35 |
# value directly.
|
36 |
#
|
37 |
$hbs.register_helper('save') {|context, name, text| |
38 |
#
|
39 |
# If the text parameter isn't there, then it is the
|
40 |
# goPress format all combined into the name. Split it
|
41 |
# out. The parameters are not String objects.
|
42 |
# Therefore, they need converted first.
|
43 |
#
|
44 |
name = String.try_convert(name) |
45 |
if name.count("|") > 0 |
46 |
parts = name.split('|') |
47 |
name = parts[0] |
48 |
text = parts[1] |
49 |
end
|
50 |
|
51 |
#
|
52 |
# Register the new helper.
|
53 |
#
|
54 |
$hbs.register_helper(name) {|context, value| |
55 |
text
|
56 |
}
|
57 |
|
58 |
#
|
59 |
# Return the text.
|
60 |
#
|
61 |
text
|
62 |
}
|
Perpustakaan Handlebars diinisialisasi dengan fungsi pembantu yang berbeda. Fungsi pembantu yang didefinisikan adalah date
, cdate
, dan save
.
Fungsi helper date
mengambil tanggal dan waktu sekarang, dan memformatnya sesuai dengan format string yang dikirimkan ke helper. cdate
adalah mirip kecuali mengirimkan tanggal dahulu. Helper save
memungkinkan Anda untuk menentukan name
dan value
. Ini menciptakan helper baru dengan nama name
dan mengembalikan value
-nya. Ini memungkinkan Anda untuk membuat variabel yang ditentukan sekali dan mempengaruhi banyak lokasi. Fungsi ini juga mengambil versi Go, yang mengharapkan sebuah string dengan name
, '|' sebagai pemisah, dan value
.
1 |
#
|
2 |
# Load Server Data.
|
3 |
#
|
4 |
$parts = {} |
5 |
$parts = JSON.parse(File.read './server.json') |
6 |
$styleDir = Dir.getwd + '/themes/styling/' + $parts['CurrentStyling'] |
7 |
$layoutDir = Dir.getwd + '/themes/layouts/' + $parts['CurrentLayout'] |
8 |
|
9 |
#
|
10 |
# Load the layouts and styles defaults.
|
11 |
#
|
12 |
$parts["layout"] = File.read $layoutDir + '/template.html' |
13 |
$parts["404"] = File.read $styleDir + '/404.html' |
14 |
$parts["footer"] = File.read $styleDir + '/footer.html' |
15 |
$parts["header"] = File.read $styleDir + '/header.html' |
16 |
$parts["sidebar"] = File.read $styleDir + '/sidebar.html' |
17 |
|
18 |
#
|
19 |
# Load all the page parts in the parts directory.
|
20 |
#
|
21 |
Dir.entries($parts["Sitebase"] + '/parts/').select {|f| |
22 |
if !File.directory? f |
23 |
$parts[File.basename(f, ".*")] = File.read $parts["Sitebase"] + '/parts/' + f |
24 |
end
|
25 |
}
|
26 |
|
27 |
#
|
28 |
# Setup server defaults:
|
29 |
#
|
30 |
port = $parts["ServerAddress"].split(":")[2] |
31 |
set :port, port |
Bagian selanjutnya dari kode ini adalah untuk memuat item yang dapat disimpan dalam cache dari situs web. Ini adalah segalanya dalam gaya dan tata letak tema Anda, dan item di sub-direktori parts
. Variabel global, $parts
, pertama kali dimuat dari file server.json
. Informasi itu kemudian digunakan untuk memuat item yang tepat untuk tata letak dan tema yang ditentukan. Mesin template Handlebars menggunakan informasi ini untuk mengisi template.
1 |
#
|
2 |
# Define the routes for the CMS.
|
3 |
#
|
4 |
get '/' do |
5 |
page "main" |
6 |
end
|
7 |
|
8 |
get '/favicon.ico', :provides => 'ico' do |
9 |
File.read "#{$parts['Sitebase']}/images/favicon.ico" |
10 |
end
|
11 |
|
12 |
get '/stylesheets.css', :provides => 'css' do |
13 |
File.read "#{$parts["Sitebase"]}/css/final/final.css" |
14 |
end
|
15 |
|
16 |
get '/scripts.js', :provides => 'js' do |
17 |
File.read "#{$parts["Sitebase"]}/js/final/final.js" |
18 |
end
|
19 |
|
20 |
get '/images/:image', :provides => 'image' do |
21 |
File.read "#{$parts['Sitebase']}/images/#{parms['image']}" |
22 |
end
|
23 |
|
24 |
get '/posts/blogs/:blog' do |
25 |
post 'blogs', params['blog'], 'index' |
26 |
end
|
27 |
|
28 |
get '/posts/blogs/:blog/:post' do |
29 |
post 'blogs', params['blog'], params['post'] |
30 |
end
|
31 |
|
32 |
get '/posts/news/:news' do |
33 |
post 'news', params['news'], 'index' |
34 |
end
|
35 |
|
36 |
get '/posts/news/:news/:post' do |
37 |
post 'news', params['news'], params['post'] |
38 |
end
|
39 |
|
40 |
get '/:page' do |
41 |
page params['page'] |
42 |
end
|
Bagian selanjutnya berisi definisi untuk semua rute. Sinatra adalah server yang sesuai REST yang lengkap. Tapi untuk CMS ini, saya hanya akan menggunakan kata kerja get
. Setiap rute mengambil item dari rute yang akan dikirimkan ke fungsi untuk menghasilkan halaman yang benar. Di Sinatra, sebuah nama yang didahului oleh sebuah kolon yang menentukan bagian dari rute yang akan dikirmkan ke handler rute. Item ini ada dalam tabel hash params
.
1 |
#
|
2 |
# Various functions used in the making of the server:
|
3 |
#
|
4 |
|
5 |
#
|
6 |
# Function: page
|
7 |
#
|
8 |
# Description: This function is for processing a page
|
9 |
# in the CMS.
|
10 |
#
|
11 |
# Inputs:
|
12 |
# pg The page name to lookup
|
13 |
#
|
14 |
def page(pg) |
15 |
processPage $parts["layout"], "#{$parts["Sitebase"]}/pages/#{pg}" |
16 |
end
|
Fungsi page
mendapatkan nama halaman dari rute dan mengirimkan tata letak di variabel $parts
bersamaan dengan path lengkap ke file halaman yang dibutuhkan untuk fungsi processPage
. Fungsi processPage
mengambil informasi ini dan membuat halaman yang tepat, yang kemudian dikembalikan. Di Ruby, keluaran dari fungsi terakhir adalah nilai kembalian untuk fungsinya.
1 |
#
|
2 |
# Function: post
|
3 |
#
|
4 |
# Description: This function is for processing a post type
|
5 |
# page in the CMS. All blog and news pages are
|
6 |
# post type pages.
|
7 |
#
|
8 |
# Inputs:
|
9 |
# type The type of the post
|
10 |
# cat The category of the post (blog, news)
|
11 |
# post The actual page of the post
|
12 |
#
|
13 |
def post(type, cat, post) |
14 |
processPage $parts["layout"], "#{$parts["Sitebase"]}/posts/#{type}/#{cat}/#{post}" |
15 |
end
|
Fungsi post
sama seperti fungsi page
, kecuali berfungsi untuk semua halaman berjenis posting. Fungsi ini mengharapkan type
posting, category
posting, dan post
itu sendiri. Ini akan membuat alamat untuk menampilkan halaman yang benar.
1 |
#
|
2 |
# Function: figurePage
|
3 |
#
|
4 |
# Description: This function is to figure out the page
|
5 |
# type (ie: markdown, HTML, jade, etc), read
|
6 |
# the contents, and translate it to HTML.
|
7 |
#
|
8 |
# Inputs:
|
9 |
# page The address of the page
|
10 |
# without its extension.
|
11 |
#
|
12 |
def figurePage(page) |
13 |
result = "" |
14 |
|
15 |
if File.exist? page + ".html" |
16 |
#
|
17 |
# It's an HTML file.
|
18 |
#
|
19 |
result = File.read page + ".html" |
20 |
elsif File.exist? page + ".md" |
21 |
#
|
22 |
# It's a markdown file.
|
23 |
#
|
24 |
result = Kramdown::Document.new(File.read page + ".md").to_html |
25 |
|
26 |
#
|
27 |
# Fix the fancy quotes from Kramdown. It kills
|
28 |
# the Handlebars parser.
|
29 |
#
|
30 |
result.gsub!("“","\"") |
31 |
result.gsub!("”","\"") |
32 |
elsif File.exist? page + ".amber" |
33 |
#
|
34 |
# It's a jade file. Slim doesn't support
|
35 |
# macros. Therefore, not as powerful as straight jade.
|
36 |
# Also, we have to render any Handlebars first
|
37 |
# since the Slim engine dies on them.
|
38 |
#
|
39 |
File.write("./tmp.txt",$hbs.compile(File.read page + ".amber").call($parts)) |
40 |
result = Slim::Template.new("./tmp.txt").render() |
41 |
else
|
42 |
#
|
43 |
# Doesn't exist. Give the 404 page.
|
44 |
#
|
45 |
result = $parts["404"] |
46 |
end
|
47 |
|
48 |
#
|
49 |
# Return the results.
|
50 |
#
|
51 |
return result |
52 |
end
|
Fungsi figurePage
menggunakan fungsi processPage
untuk membaca konten halaman dari sistem file. Fungsi ini menerima path lengkap ke file tanpa ekstensi. figurePage
kemudian menguji untuk file dengan nama yang diberikan dengan ekstensi html
untuk membaca file HTML. Pilihan kedua adalah untuk ekstensi md
untuk file Markdown.
Terakhir, ia memeriksa ekstensi amber
untuk file Jade. Ingat: Amber adalah nama perpustakaan untuk memproses file sintaks Jade di Go. Saya menyimpannya sama untuk antar-fungsionalitas. File HTML dikirimkan kembali, sementara semua file Markdown dan Jade dikonversi ke HTML sebelum dikembalikan.
Jika file tidak ditemukan, pengguna akan menerima halaman 404
. Dengan cara ini, halaman "page not found" Anda terlihat seperti halaman lain kecuali isinya.
1 |
#
|
2 |
# Function: processPage
|
3 |
#
|
4 |
# Description: The function processes a page by getting
|
5 |
# its contents, combining with all the page
|
6 |
# parts using Handlebars, and processing the
|
7 |
# shortcodes.
|
8 |
#
|
9 |
# Inputs:
|
10 |
# layout The layout structure for the page
|
11 |
# page The complete path to the desired
|
12 |
# page without its extension.
|
13 |
#
|
14 |
def processPage(layout, page) |
15 |
#
|
16 |
# Get the page contents and name.
|
17 |
#
|
18 |
$parts["content"] = figurePage page |
19 |
$parts["PageName"] = File.basename page |
20 |
|
21 |
#
|
22 |
# Run the page through Handlebars engine.
|
23 |
#
|
24 |
begin
|
25 |
pageHB = $hbs.compile(layout).call($parts) |
26 |
rescue
|
27 |
pageHB = " |
28 |
Render Error
|
29 |
|
30 |
"
|
31 |
end
|
32 |
|
33 |
#
|
34 |
# Run the page through the shortcodes processor.
|
35 |
#
|
36 |
pageSH = processShortCodes pageHB |
37 |
|
38 |
#
|
39 |
# Run the page through the Handlebar engine again.
|
40 |
#
|
41 |
begin
|
42 |
pageFinal = $hbs.compile(pageSH).call($parts) |
43 |
rescue
|
44 |
pageFinal = " |
45 |
Render Error
|
46 |
|
47 |
" + pageSH |
48 |
end
|
49 |
|
50 |
#
|
51 |
# Return the results.
|
52 |
#
|
53 |
return pageFinal |
54 |
end
|
Fungsi processPage
melakukan semua perluasan template pada data halaman. Dimulai dengan memanggil fungsi figurePage
untuk mendapatkan isi halaman. Kemudian memproses tata letak diteruskan ke sana dengan Handlebars untuk memperluas template.
Kemudian fungsi processShortCode
akan menemukan dan memproses semua shortcode di halaman. Hasilnya kemudian diteruskan ke Handlebars untuk kedua kalinya untuk memproses macro yang ditinggalkan oleh shortcode. Pengguna menerima hasil akhirnya.
1 |
#
|
2 |
# Function: processShortCodes
|
3 |
#
|
4 |
# Description: This function takes the page and processes
|
5 |
# all of the shortcodes in the page.
|
6 |
#
|
7 |
# Inputs:
|
8 |
# page The contents of the page to
|
9 |
# process.
|
10 |
#
|
11 |
def processShortCodes(page) |
12 |
#
|
13 |
# Initialize the result variable for returning.
|
14 |
#
|
15 |
result = "" |
16 |
|
17 |
#
|
18 |
# Find the first shortcode
|
19 |
#
|
20 |
scregFind = /\-\[([^\]]*)\]\-/ |
21 |
match1 = scregFind.match(page) |
22 |
if match1 != nil |
23 |
#
|
24 |
# We found one! get the text before it
|
25 |
# into the result variable and initialize
|
26 |
# the name, param, and contents variables.
|
27 |
#
|
28 |
name = "" |
29 |
param = "" |
30 |
contents = "" |
31 |
nameLine = match1[1] |
32 |
loc1 = scregFind =~ page |
33 |
result = page[0, loc1] |
34 |
|
35 |
#
|
36 |
# Separate out the nameLine into a shortcode
|
37 |
# name and parameters.
|
38 |
#
|
39 |
match2 = /(\w+)(.*)*/.match(nameLine) |
40 |
if match2.length == 2 |
41 |
#
|
42 |
# Just a name was found.
|
43 |
#
|
44 |
name = match2[1] |
45 |
else
|
46 |
#
|
47 |
# A name and parameter were found.
|
48 |
#
|
49 |
name = match2[1] |
50 |
param = match2[2] |
51 |
end
|
52 |
|
53 |
#
|
54 |
# Find the closing shortcode
|
55 |
#
|
56 |
rest = page[loc1+match1[0].length, page.length] |
57 |
regEnd = Regexp.new("\\-\\[\\/#{name}\\]\\-") |
58 |
match3 = regEnd.match(rest) |
59 |
if match3 != nil |
60 |
#
|
61 |
# Get the contents the tags enclose.
|
62 |
#
|
63 |
loc2 = regEnd =~ rest |
64 |
contents = rest[0, loc2] |
65 |
|
66 |
#
|
67 |
# Search the contents for shortcodes.
|
68 |
#
|
69 |
contents = processShortCodes(contents) |
70 |
|
71 |
#
|
72 |
# If the shortcode exists, run it and include
|
73 |
# the results. Otherwise, add the contents to
|
74 |
# the result.
|
75 |
#
|
76 |
if $shortcodes.include?(name) |
77 |
result += $shortcodes[name].call(param, contents) |
78 |
else
|
79 |
result += contents |
80 |
end
|
81 |
|
82 |
#
|
83 |
# process the shortcodes in the rest of the
|
84 |
# page.
|
85 |
#
|
86 |
rest = rest[loc2 + match3[0].length, page.length] |
87 |
result += processShortCodes(rest) |
88 |
else
|
89 |
#
|
90 |
# There wasn't a closure. Therefore, just
|
91 |
# send the page back.
|
92 |
#
|
93 |
result = page |
94 |
end
|
95 |
else
|
96 |
#
|
97 |
# No shortcodes. Just return the page.
|
98 |
#
|
99 |
result = page |
100 |
end
|
101 |
|
102 |
return result |
103 |
end
|
Fungsi processShortCodes
mengambil teks yang diberikan, menemukan masing-masing shortcode, dan menjalankan shortcode yang ditentukan dengan argumen dan isi shortcode. Saya menggunakan shortcode routine untuk memproses isi shortcode juga.
Shortcode adalah tag mirip HTML yang menggunakan -[
dan ]-
untuk membatasi tag pembuka dan tag penutup -[/
dan ]-
. Tag pembuka berisi parameter untuk shortcode juga. Oleh karena itu, contoh shortcode adalah:
1 |
-[box]- |
2 |
This is inside a box. |
3 |
-[/box]- |
Shortcode ini mendefinisikan shortcode box
tanpa parameter dengan isi <p>This is inside a box.</p>
. Shortcode box
membungkus isinya dalam HTML yang sesuai untuk menghasilkan kotak di sekitar teks dengan teks yang terpusat di dalam kotak. Jika nanti Anda ingin mengubah bagaimana box
itu di-render, Anda hanya perlu mengubah definisi shortcode. Ini menghemat banyak pekerjaan.
1 |
#
|
2 |
# Data Structure: $shortcodes
|
3 |
#
|
4 |
# Description: This data structure contains all
|
5 |
# the valid shortcodes names and the
|
6 |
# function. All shortcodes should
|
7 |
# receive the arguments and the
|
8 |
# that the shortcode encompasses.
|
9 |
#
|
10 |
$shortcodes = { |
11 |
"box" => lambda { |args, contents| |
12 |
return("#{contents}") |
13 |
},
|
14 |
'Column1'=> lambda { |args, contents| |
15 |
return("#{contents}") |
16 |
},
|
17 |
'Column2' => lambda { |args, contents| |
18 |
return("#{contents}") |
19 |
},
|
20 |
'Column1of3' => lambda { |args, contents| |
21 |
return("#{contents}") |
22 |
},
|
23 |
'Column2of3' => lambda { |args, contents| |
24 |
return("#{contents}") |
25 |
},
|
26 |
'Column3of3' => lambda { |args, contents| |
27 |
return("#{contents}") |
28 |
},
|
29 |
'php' => lambda { |args, contents| |
30 |
return("#{contents}") }, 'js' => lambda { |args, contents| return("#{contents}") }, "html" => lambda { |args, contents| return("#{contents}") }, 'css' => lambda {|args, contents| return("#{contents}") } } |
Hal terakhir dalam file adalah tabel hash $shortcodes
yang berisi rutinitas shortcode. Ini adalah shortcode sederhana, tapi Anda bisa membuat shortcode lain menjadi serumit yang Anda inginkan.
Semua shortcode harus menerima dua parameter: args
and contents
. String ini berisi parameter shortcode dan isi shortcode-nya. Karena shortcode berada di dalam tabel hash, saya menggunakan fungsi lambda untuk mendefinisikannya. Fungsi lambda adalah fungsi tanpa nama. Satu-satunya cara untuk menjalankan fungsi ini adalah dari array hash.
Menjalankan Server
Setelah Anda membuat file rubyPress.rb
dengan isi di atas, Anda dapat menjalankan server dengan:
1 |
ruby rubyPress.rb |
Karena kerangka Sinatra bekerja dengan struktur Ruby on Rails Rack, Anda dapat menggunakan Pow untuk menjalankan server. Pow akan menyiapkan file host sistem Anda untuk menjalankan server Anda secara lokal seperti pada situs host. Anda bisa menginstal Pow with Powder menggunakan perintah berikut di baris perintah:
1 |
gem install powder
|
2 |
powder install
|
Powder adalah rutin baris perintah untuk mengelola situs Pow di komputer Anda. Agar Pow bisa melihat situs Anda, Anda harus membuat soft link ke direktori proyek Anda di direktori ~/.pow
. Jika server berada di direktori /Users/test/Documents/rubyPress
, Anda akan menjalankan perintah berikut:
1 |
cd ~/.pow
|
2 |
ln -s /Users/test/Documents/rubyPress rubyPress |
Ln -s
membuat soft link ke direktori yang ditentukan terlebih dahulu, dengan nama yang ditentukan kedua. Pow kemudian akan membuat domain di sistem Anda dengan nama soft link. Pada contoh di atas, masuk ke situs web http://rubyPress.dev
di browser akan memuat halaman dari server.
Untuk memulai server, ketik berikut ini setelah membuat soft link:
1 |
powder start |
Untuk memuat ulang server setelah membuat beberapa perubahan kode, ketik berikut ini:
1 |
powder restart |



Halaman Utaman rubyPressPergi ke situs web di browser akan menghasilkan gambar di atas. Pow akan membuat situs di http://rubyPress.dev
. Tidak peduli metode mana yang Anda gunakan untuk meluncurkan situs ini, Anda akan melihat halaman hasil yang sama.
Kesimpulan
Nah, Anda sudah melakukannya. CMS lainnya, tapi kali ini di Ruby. Versi ini adalah versi terpendek dari semua CMS yang dibuat dalam seri ini. Bereksperimenlah dengan kode dan lihat bagaimana Anda dapat memperluas kerangka dasar ini.