Chrome Extension with ECMAScript

Google Chrome拡張の概要とECMA-262 5th Edition

Shogo Ohta, 2010-4-23

はじめに

自己紹介

名前:
太田 昌吾
os0x(blog, Twitter)
Chrome ExtensionsのAPI Expertで、Chromium Extensions Japanグループの管理人
お仕事:
Chrome拡張について記事を書いたり、JavaScriptの記事を書いたりしています

2010/3/25にGoogle Chrome OS 最新技術と戦略を完全ガイド: 小池良次, 中島聡, 伊藤千光, 太田昌吾, まえだひさこ, 向井領治が発売!

Software Design 2010年5月号にChrome拡張の特集25ページが掲載。

得意分野:
ほぼJavaScript専門、なかでもクロスブラウザや高速化が得意です
簡単な経歴:
元々、OperaのUserJavaScriptやGreasemonkeyを良く書いていた(oAutoPagerizeとか)
ChromeでもUserScriptsが動くらしい→拡張もできる(ようになる)らしい→UserJavaScriptより色々できて楽しい!→拡張作ったり、記事を書いていたらgihyo.jpで連載をすることに→そのまま勢いでAPI Expertに

Chromium Extensions Japan

Google Chromeの拡張機能について、APIの最新情報や開発のノウハウなどを日本語で共有するGoogle 準公式のコミュニティ

2009年12月にできたばかり。是非ご参加ください。

Google Chromeとは、Chrome拡張とは

Chromium
レンダリングエンジンにWebKitを、JavaScriptエンジンにV8を採用したオープンソースのウェブブラウザ
Google Chrome
ChromiumをGoogleがカスタムして公開しているブラウザ
Chrome拡張
Chrome/Chromium上で動く、ウェブブラウザに特定の機能を追加するHTMLベースのアプリケーション

Chrome vs Chromium

Google ChromeChromium補足
ロゴ
開発者Googler誰でも(多くはGoogler)
障害報告あり(ONにした場合)なし
利用統計あり(ONにした場合)なし
HTML5のVideo、AudioH.264、AAC、MP3、Vorbis、Theora デフォルトではVorbisとTheoraのみFFmpegを使用しているので、自己責任においてこれを差し替えれば対応フォーマットを変えることができる
ページ翻訳機能ありなし※日本語圏での評判はいまひとつ
サンドボックス(セキュリティ機構)常にON場合によってはOFFになっていることも非公式なパッケージなどは要注意
品質保証事前にテストされてからリリースされるNightly Buildsのみ

Chromiumの特徴

オープンソースなので、すべてのソースが読める。カスタムしてビルドを作れる。

オープンソースコミュニティのパワーで、日々開発が進んでいる。また、Chromiumのカスタム版が数多く存在する。

今すぐ開発に参加できる chromium - Project Hosting on Google Codeへ!

Chromeの特徴

stable(安定版)、beta(テスト版)、dev(開発版)の3つのリリースがある

安定版は名前の通り、一般ユーザーが使用するリリース、(セキュリティフィックスを除いて)半年に1回程度のリリース

テスト版は、主に安定版のリリース前に、安定化のためにリリースされる、時期によってはまったくリリースされないが、1ヶ月に1回程度のリリース

開発版は、安定版のリリース後、次のバージョンに向けた新機能追加を主目的(そのため不安定になることも多々)にリリースされる、1週間に1回程度のリリース
※開発版もリリース前は安定する。修正が多く適用されている分だけテスト版より安定することも。

Chromeの構成

JavaScriptエンジンはV8というChromeのために開発したオープンソースのエンジンを搭載

レンダリングエンジンはWebKitのWebCoreを採用

WebCoreは、WebKitのレンダリング周りの実装(JavaScript部分はJavaScriptCore)

V8とWebCoreを繋ぐ部分にバグが潜みやすい…(Safariでは正常なのに、Chromeだとなんかおかしくなるなぁという現象はここに起因することが多い)

最近はChromiumチームが積極的にWebKitにコミットするようになっていて、かなり改善しつつある

Chrome拡張とは

HTML/CSS/JavaScriptで作る、ブラウザをもっと便利にするモノ

(2010年末に登場予定の)Chrome OSではアプリケーションの役割を果たす?

とにかく作るのは簡単、でも制約は多い

Chrome拡張の例

実物を見てもらうのが一番

公式の拡張ギャラリーには2010年4月9日時点で4000以上の拡張が登録されています。

Google Mail Checker - Google Chrome 拡張機能ギャラリー
高機能版はGoogle Mail Checker Plus - Google Chrome 拡張機能ギャラリー(最近使い始めましたが便利です)

Hatena Bookmark GoogleChrome extension - Google Chrome 拡張機能ギャラリー(はてな公式のChrome拡張、こちらも便利です)

Chrome拡張の系譜

Chrome拡張開発者のリーダー的存在のAaron Boodman氏は元々FirefoxのAdd-onであるGreasemonkeyの創始者

Chrome拡張もGreasemonkeyのコンセプトを引継ぎつつ、開発者のおもちゃ的なGreasemonkeyをより多くのユーザーに安全に使ってもらうために作られた

FirefoxのAdd-onの縮小版ではなく、Greasemonkeyの改良版といえる

Chrome拡張でできること(API)

APIの詳細は公式ドキュメントとその非公式日本語訳(@nao58)へ

APIはまだまだ少ないが、バックグラウンド処理、クロスドメイン通信、データの永続化をベースに、可能性は無限

Chrome5では履歴を扱うAPIが追加される(chrome.history)

実験段階のAPI(experimental.* APIs)もあります

Chrome拡張のセキュリティ

拡張コンテキスト、コンテントコンテキスト、ページコンテキストの3つのコンテキストが存在し、それぞれは完全に分かれているので、お互いが干渉してしまうことはない。さらに、拡張同士も独立したコンテキストで実行される。

拡張コンテキストはタブ操作やクロスドメイン通信などの特権を実行でき、コンテントコンテキストと通信したり、スクリプトを実行したりといったことができます。

コンテントコンテキスト(Content Scripts)は特権を持っていませんが、読み込んだページのDOMを操作することができ、拡張コンテキストと相互に通信できます。

ページコンテキストは通常のウェブページで実行されるコンテキストで、コンテントコンテキストとはDOM経由でしかやり取りできませんし、拡張コンテキストとは完全に分断されています

「悪意のあるページで拡張の特権を利用されてしまう」といったことが起こりにくい仕様になっているが、開発者から見ると面倒に感じる部分も…

拡張同士の関係

拡張同士はお互いに独立しており、特にBackground Pageのプロセスは完全に分かれている

拡張同士で連携するには、chrome.extension.sendRequestなどのAPIを使用する必要がある

拡張はそれぞれIDを持っており、拡張機能のページでデベロッパー モードにすると確認できる。また、公式ギャラリーのURLにもIDがそのまま使われている。

このIDはドメインに相当するもので、CookieやlocalStorageなどはこのIDに関連付けて保存される。また、拡張コンテキスト上でlocation.hostをみるとやはりIDが使われている。

なお、拡張のスキームはchrome-extension://である。IDと組み合わせて、例えば chrome-extension://dnlfpnhinnjdgmjfpccajboogcjocdla/images/favicon32.png がフルパスとなる。

通常のウェブページと同様で、ドメインが違えばクロスドメインの制約を受けるので、拡張間でのXMLHttpRequestなどはできない。

manifest.json

{
   
"name": "simple note",
   
"description":"簡易メモ帳です",
   
"browser_action": {
     
"default_icon": "pad19.png",
     
"default_title": "simple note",
     
"popup": "popup.html"
   
},
   
"icons": {
     
"128": "pad128.png",
     
"48": "pad48.png"
   
},
   
"permissions": ["tabs" ,"http://*/", "https://*/"],
   
"version": "0.0.1"
}

拡張を定義するjsonファイル。名前、バージョンのほか、アイコンや拡張の持つ権限、ContentScriptsを実行するURLの定義など。

manifest.jsonで宣言していない機能は使用できない。(URLのほか、tabs/bookmarksに、Chrome5からはnotifications/historyとexperimentalを指定できます)

セキュリティを高めつつ、機械的にその拡張の権限を判断して、インストールするユーザーに警告を出すことができる。

Chrome拡張の作り方

拡張を作るにあたって必要なものは特にありません。あえて挙げるなら、アイコンは用意しておきましょう。

まず、manifest.jsonを用意します。

{
 
"name": "Hello",
 
"description": "Chrome拡張入門",
 
"version": "0.1"
}

このmanifest.jsonだけで拡張として読み込むことができます。もちろん、何の機能もありません。

{
 
"name": "Hello World",
 
"description": "Chrome拡張入門",
 
"version": "0.1",
 
"browser_action": {
   
"default_icon": "icon.png",
   
"popup": "popup.html"
 
},
 
"icons": {
   
"48": "icon.png"
 
}
}

browser_actionとiconsを追加しました。popupには以下のhtmlを用意しました。

<!doctype html>
<html>
<style>
*{
  margin
: 0px;
  padding
: 0px;
}
body
> h1{
  width
:300px;
  text
-align:center;
  background
: -webkit-gradient(
    linear
, left top, left bottom, from(#06c), to(#39f)
 
);
  border
-radius:4px;
  margin
:0px;
  padding
:1em;
  color
:white;
  text
-shadow: 3px 3px 3px black;
  line
-height:1.4;
}
</style>
<h1>Chrome extensions</h1>
<script>
document
.querySelector('h1').onclick=function(){
 
this.innerText = 'Chrome拡張入門';
}
</script>
</html>

パッケージ化

chrome://extensions/ で「拡張機能のパッケージ化」からパッケージを作成できます。

Constellation's crxmake at master - GitHubでCUI環境でもパッケージングができます。

出来上がったcrxファイルをChromeにドロップすると、インストールダイアログが現れて、拡張としてインストールできます。インストールした拡張は(Windowsの場合)以下のパスに保存されています。

//XP
"C:\Documents and Settings\[USERNAME]\Local Settings\Application Data\Google\Chrome\User Data\Default\Extensions"
//7
"C:\Users\[USERNAME]\AppData\Local\Google\Chrome\User Data\Default\Extensions"

インストールした拡張はIDが開発時と変わってしまいます。その代わりにインストール後のmanifest.jsonには"key"というフィールドが追加されています。このkeyがあるとパッケージ化されていない拡張を読み込んだ際もパッケージ化した拡張のIDと同じものが使用できます。

なお、.crxファイルはzip形式なので、解凍することができます。

拡張のデバッグ

Web Inspectorを使用する(参考:続・先取り! Google Chrome Extensions:第6回 Firebug要らずなChromeのWeb Inspector|gihyo.jp … 技術評論社)

yet another greasemonkey

Greasemonkey Scriptへの対応

ChromeはGreasemonkey Scriptを拡張に変換してインストールする機能を備えています。

twitter.AutoPager for Greasemonkey

ただし、このGreasemonkey対応はContentScriptsとして実行されるだけなので、クロスドメイン通信などに対応していません(Chrome6での対応予定はある)

GM_xmlhttpRequest、GM_getValue/GM_setValue、GM_registerMenuCommandなどのAPI、@requireなどに未対応

そして、開発者にとって重大な問題と言えるのが、ソースを手軽に編集できないという点です

インストールしたソースを探し出し、編集して再起動か、一度無効化して有効にするという手順で編集を反映させることはできますが、Greasemonkeyの編集してリロードすれば反映されるという手軽さには到底及びません

Greasemonkey on Chrome Extension

それなら、Chrome拡張でGreasemonkeyを実装してみればいいじゃないかと思い、やってみました

Greasemonkeyを実装するうえでの課題と解決策

スクリプトの実行

Background Pageにスクリプトを持ち、表示したURLに対応するスクリプトを管理・実行させる

chrome.extension.onRequest.addListener(function(request, sender, sendResponse){
  if (request.href) {
スクリプトの管理
Options Pageで管理する。ローカルにファイルを作ることなどはできないので、localStorageにスクリプトデータを保存し、ブラウザ内で簡易編集機能を実現する(後述)
クロスドメイン通信

ContentScriptsからBackground PageにsendRequestで通信、Backgroundでクロスドメイン通信、結果をContentScriptに返却

// Content Scripts
function GM_xmlhttpRequest(opt){
  var _opt = {
    type:'GM_xmlhttpRequest',
    url:opt.url,
    method:opt.method,
    headers:opt.headers,
    overrideMimeType:opt.overrideMimeType,
    data:opt.data,
    isOnload:!!opt.onload,
    isOnerror:!!opt.onerror,
    isOnreadystatechange:!!opt.onreadystatechange
  };
  chrome.extension.sendRequest(_opt, function(res){
    if (res.state === 'load') {
      opt.onload(res.response);
    } else if (res.state === 'error') {
      opt.onerror(res.response);
    } else if (res.state === 'readystatechange') {
      opt.onreadystatechange(res.response);
    }
  });
}
// Background
chrome.extension.onRequest.addListener(function(request, sender, sendResponse){
if (request.type === 'GM_xmlhttpRequest') {
  var xhr = new XMLHttpRequest();
  var res = {
    status:0,
    statusText:'',
    readyState:0,
    responseText:'',
    responseHeaders:'',
  }
  xhr.open(request.method, request.url, false);
  if (request.isOnload) {
    xhr.onload = function(){
      Object.keys(res).forEach(function(k){
        res[k] = xhr[k];
      });
      sendResponse({response:res, state:'load'});
    };
  }
  if (request.isOnerror) {
    xhr.onerror = function(){
      Object.keys(res).forEach(function(k){
        res[k] = xhr[k];
      });
      sendResponse({response:res, state:'error'});
    };
  }
  if (request.isOnreadystatechange) {
    xhr.onreadystatechange = function(){
      Object.keys(res).forEach(function(k){
        res[k] = xhr[k];
      });
      sendResponse({response:res, state:'readystatechange'});
    };
  }
  xhr.send(request.data);
}
});
GM_getValue、GM_setValue

Backgroundページと通信して…だと非同期になってしまうので、APIのインターフェースが変わってしまう

実行前にGM_getValue、GM_setValue用のデータオブジェクトを用意し、参照・保存はそのオブジェクトに対して行い、保存の際は裏でBackground Pageに投げておく

function GM_getValue(key, default_value){
  if (__gm_value_store[key]) {
    return __gm_value_store[key];
  } else {
    return default_value;
  }
}
function GM_setValue(key, value){
  __gm_value_store[key] = value;
  var opt = {
    type:'GM_setValue',
    value:__gm_value_store,
    namespace:SCRIPT_NAME_SPACE
  };
  chrome.extension.sendRequest(opt);
}
if (request.type === 'GM_setValue') {
  localStorage[request.namespace] = JSON.stringify(request.value);
}

エディタの問題

Bespinなども考えたが、独自実装にしました。

現状の成果物はGreased Chromeにあります

Chrome拡張で使えるECMAScript5

ChromeのECMAScript5対応

ChromeのJavaScriptエンジンV8はWebKitのJavaScriptCoreと協調して、ECMAScript5の実装を積極的に進めています。

Chrome4で使用できるECMAScript5

配列の拡張メソッド
Array.prototype.(indexOf,lastIndexOf,every,some,forEach,map,filter,reduce,reduceRight)などのメソッドをサポート。あとArray.isArrayも
String.prototype.trim
文字列の前後の空白文字列(正規表現の\sに相当、改行なども含む)を取り除いた値を返す。ECMAScript5にはないが、trimLeft、trimRightも実装している
Date.now、Date.prototype.toISOString、Date.prototype.toJSON
Date.nowは日付のシリアル値(new Date().getTime()相当の値)を取得できる。
getter、setter
var data = {
  _i : 0,
  max : 100,
  min : 0,
  get index() {return this._i},
  set index(i) {
    if (i < this.min){
      this._i = this.min;
    } else if (i > this.max){
      this._i = this.max;
    } else {
      this._i = i;
    }
  }
};

なお、__defineGetter__、__defineSetter__はECMAScript5に含まれていない

document.__defineGetter__('innerHTML',function(){
  return document.documentElement.outerHTML;
});
document.__defineSetter__('innerHTML',function(html){
  document.documentElement.outerHTML = html;
});
JSON
ネイティブなJSON対応(ネイティブな実装がないときはもちろんjson2.js)。
var data = {
  a:1,
  b:[1,2],
  c:new Date(),
  f:function(a){return a;}
};
var json_text = JSON.stringify(data);
console.log(json_text);
//{"a":1,"b":[1,2],"c":"2010-01-18T14:01:36Z"}
var json_text2 = JSON.stringify(data,null,2);
console.log(json_text2);
/*
{
  "a": 1,
  "b": [
    1,
    2
  ],
  "c": "2010-01-18T14:01:36Z"
}
*/
Object.keys

オブジェクトのプロパティを配列で取得する

if (!Object.keys){
  Object.keys = function(object){
    if (!(object instanceof Object)) {
      throw new TypeError('Object.keys called on non-object')
    }
    var keys = [];
    for (var key in object){
      if(object.hasOwnProperty(key)){
        keys.push(key);
      }
    }
    return keys;
  }
}

Chrome5で使用できるECMAScript5

Object.getPrototypeOf
引数のprototypeを取得する。一部のブラウザ(といっても実質IE以外)で実装されている __proto__ に相当します
Object.getOwnPropertyNames
Object.keysと似ているが、getOwnPropertyNamesは本来なら列挙されないプロパティ(配列のlengthなど)も取得できます
Object.create

prototypeを指定して新しいオブジェクトを作るメソッドです

ECMAScript5ではオブジェクトのプロパティに対して、writable、enumerable、configurableという属性が追加されました。

writableはそのプロパティの値を書き換え可能であるか、enumerableはそのプロパティがfor inループで列挙されるかどうか、configurableはこれらの属性を書き換え可能であるかをそれぞれ真偽値で設定します。また、value、writableの代わりにget、setでAccessorとして定義することも可能です。

var nullObject = Object.create(null);
console.log(Object.getPrototypeOf(nullObject)); // null
var object = Object.create(Object.prototype); // var object = {};
console.dir(Object.getPrototypeOf(object));
/*
Object:
  __defineGetter__: function __defineGetter__() { [native code] }
  __defineSetter__: function __defineSetter__() { [native code] }
  __lookupGetter__: function __lookupGetter__() { [native code] }
  __lookupSetter__: function __lookupSetter__() { [native code] }
  constructor: function Object() { [native code] }
  hasOwnProperty: function hasOwnProperty() { [native code] }
  isPrototypeOf: function isPrototypeOf() { [native code] }
  propertyIsEnumerable: function propertyIsEnumerable() { [native code] }
  toLocaleString: function toLocaleString() { [native code] }
  toString: function toString() { [native code] }
  valueOf: function valueOf() { [native code] }
*/
object = Object.create(Object.prototype,{
  v:{
    value:1,
    writable:false,
    enumerable:false,
    configurable:false
  }
});
object.v = 10;
console.log(object.v); // 1
var data = Object.create(Object.prototype,{
  _i : {
    value: 0,
    writable: true,
    enumerable:false,
    configurable:false
  },
  max : {
    value: 100,
    writable: true,
    enumerable:false,
    configurable:true
  },
  min : {
    value: 0,
    writable: true,
    enumerable:false,
    configurable:true
  },
  index: {
    enumerable:true,
    configurable:true,
    get: function() {return this._i},
    set: function(i) {
      if (i < this.min){
        this._i = this.min;
      } else if (i > this.max){
        this._i = this.max;
      } else {
        this._i = i;
      }
    }
  }
});
console.dir(data);
/*
  Object
    _i: 0
    index: 0
    max: 100
    min: 0
    __proto__: Object
*/
console.log(Object.keys(data));
/*
["index"]
*/
data.index=10;
console.log(data.index); // 10
data.index=101;
console.log(data.index); // 100
Object.defineProperty、Object.defineProperties
既存のオブジェクトに属性付きプロパティを定義できる。Object.prototypeを(比較的)安全に拡張できる。
if (Object.defineProperty && !Object.prototype.toSource){
  Object.defineProperty(Object.prototype, 'toSource', {
    value : function(indent) {
      return uneval(this,indent);
    },
    writable : true,
    enumerable : false,
    configurable : false
  });
}
function uneval(obj,indent){// FIXME
  var r = [];
  function _uneval(obj, _indent, __indent){
    var $indent = '';
    if (typeof _indent === 'string') {
      $indent = '\n' + _indent;
    } else if (isFinite(_indent)) {
      $indent = '\n' + new Array(_indent+1).join(' ');
    }
    if (typeof __indent === 'string') {
      __indent = '\n' + __indent;
    } else if (isFinite(__indent)) {
      __indent = '\n' + new Array(__indent+1).join(' ');
    }
    __indent || (__indent = '');
    var type = typeof obj;
    if (type === 'object' && obj){
      if (Array.isArray(obj)) {
        return '[' + $indent + obj.map(function(v){
          return _uneval(v, _indent + indent);
        }).join(','+$indent) + __indent + ']';
      } else {
        return '{' + $indent + Object.keys(obj).map(function(k){
          return '"' + k + '":' + _uneval(obj[k], _indent+indent, _indent);
        }).join(',' + $indent) + __indent +'}';
      }
    } else if (type === 'function') {
      return obj.toString();
    } else if (type  === 'undefined') {
      return void 0;
    } else {
      return JSON.stringify(obj,null,indent);
    }
  }
  return '(' + _uneval(obj,indent) + ')';
}

これ以外のECMAScript5での変更点

via ECMAScript 5: The Definitive Slides

これらは2010年4月23日時点でまだ実装されていません。特にStrict modeなどは実際に実装されてみないと正しくstrictな記述ができているか保証できないので、今のうちからuse strictを使うのは避けたほうが良いでしょう。

Strict mode

JavaScriptにStrictモードが追加されました。ただし、文法的なStrictではなく、セマンティックなStrictです(文法的な事前チェック(lint)ができるのではなく、実行時に例外を投げる)。

ソースコード中に"use strict";という何もしない文字列リテラル(将来的にはクォートはなくなる予定)を置くことで、Strictモードになります。

エンジンが独自に"use ~~";な命令を実装してもOK

strictモードはあくまで定義したスコープ内だけで有効であり、ライブラリとして呼び出された関数がその影響を受けることはない

(function(){
  "use strict";
  function strictFunc(){
    i = 0;// error
  }
  try {
    strictFunc();
  } catch(e) {
  }
  notStrict();
})();

function notStrict(){
  i = 1;
}
Strictモードは以下の特徴を持っています
//Strictモードかどうかをチェックする方法
var strict = (function() {
    return !this; 
}());

// Strictモードをサポートしているかチェック
var hasstrict = (function() {
    "use strict";
    return !this;
}());

その他のECMAScript5での変更点

Chrome拡張で使えるHTML5

ChromeのHTML5対応:関連API

canvas
対応済み
video, audio
対応済み(対応フォーマットは前述の通り)
Web Workers
対応済み
Web Storage
Chrome4ではlocalStorageのみ対応、Chrome5でsessionStorageも対応予定
コミュニケーションAPI
対応済み(ただし、Chrome4ではテキストのみの送受信)
Web Sockets
対応済み(ただし、WokerからのSocketsはChrome5で対応予定)
HTML Ruby
対応済み
Web Fonts
対応済み
Web SQL Database
対応済み(残念ながら標準化はストップしてしまいましたが…)
Desktop notifications
Chrome5で正式対応予定
HTML Forms
一部のみ対応、Chrome5ではバリデーションなどを追加、UIはChrome6以降
Application Cache
Chrome5で対応予定
Geolocation API
Chrome5で対応
Drag & drop
一部実装されているが、Chrome5で完全に対応する予定
File API
Chrome5で対応予定
Indexed Database API
Chrome6で対応予定、2010/3/11に動作したとの第一報あり

最新の情報は、Web Platform Status (The Chromium Projects)にまとまっています

ChromeのHTML5対応:HTMLパーサー

<p>第1パラグラフ
<section>セクション</section>
<p>第2パラグラフ

わざとらしい例ですが、パーサーがsectionに対応しているか否かで、下記のように解釈が変わります。

ChromeFirefox
section要素未対応
Chrome4

Firefox3.6
section要素対応
Chrome5

Firefox3.6(html5.enable)

Chrome拡張からHTML5へ

元々、Chrome拡張のAPIの仕様だったDesktop Notificationsが、2010年1月にW3C Editor's Draftとして(Web Notifications)公開されました。

このように、Chrome拡張は各種APIがHTML5をベースとしており、同時に、Chrome拡張からHTML5にフィードバックする、という相互作用の関係があります

HTML5を使いたい!という方は、是非Chrome拡張を!!

Appendix

Chrome Extensionsの参考URL

ECMAScript5関連の参考URL

この資料についてのお話

この資料自体がHTML5のサンプルとなるよう作ってあります。

HTML5 Validator
Validator.nu (X)HTML5 Validator
(X)HTML5 validation results for http://ss-o.net/chrome/extension/introduction.html
HTML5 Outliner
HTML 5 Outliner
Outline
Outlineと目次
HTML5 のセクションアウトラインを生成してみよう! - IT戦記のOutliner.jsを使用して、右上の目次を自動生成しています。

Creative Commons License
This work is in the Public Domain.

設定 閉じる


10% 500%