You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

704 lines
20 KiB

  1. <?php
  2. /**
  3. * DokuWiki StyleSheet creator
  4. *
  5. * @license GPL 2 (http://www.gnu.org/licenses/gpl.html)
  6. * @author Andreas Gohr <andi@splitbrain.org>
  7. */
  8. use LesserPHP\Lessc;
  9. use dokuwiki\StyleUtils;
  10. use dokuwiki\Cache\Cache;
  11. use dokuwiki\Extension\Event;
  12. if (!defined('DOKU_INC')) define('DOKU_INC', __DIR__ . '/../../');
  13. if (!defined('NOSESSION')) define('NOSESSION', true); // we do not use a session or authentication here (better caching)
  14. if (!defined('DOKU_DISABLE_GZIP_OUTPUT')) define('DOKU_DISABLE_GZIP_OUTPUT', 1); // we gzip ourself here
  15. if (!defined('NL')) define('NL', "\n");
  16. require_once(DOKU_INC . 'inc/init.php');
  17. // Main (don't run when UNIT test)
  18. if (!defined('SIMPLE_TEST')) {
  19. header('Content-Type: text/css; charset=utf-8');
  20. css_out();
  21. }
  22. // ---------------------- functions ------------------------------
  23. /**
  24. * Output all needed Styles
  25. *
  26. * @author Andreas Gohr <andi@splitbrain.org>
  27. */
  28. function css_out()
  29. {
  30. global $conf;
  31. global $lang;
  32. global $config_cascade;
  33. global $INPUT;
  34. if ($INPUT->str('s') == 'feed') {
  35. $mediatypes = ['feed'];
  36. $type = 'feed';
  37. } else {
  38. $mediatypes = ['screen', 'all', 'print', 'speech'];
  39. $type = '';
  40. }
  41. // decide from where to get the template
  42. $tpl = trim(preg_replace('/[^\w-]+/', '', $INPUT->str('t')));
  43. if (!$tpl) {
  44. $tpl = $conf['template'];
  45. }
  46. // load style.ini
  47. $styleUtil = new StyleUtils($tpl, $INPUT->bool('preview'));
  48. $styleini = $styleUtil->cssStyleini();
  49. // cache influencers
  50. $tplinc = tpl_incdir($tpl);
  51. $cache_files = getConfigFiles('main');
  52. $cache_files[] = $tplinc . 'style.ini';
  53. $cache_files[] = DOKU_CONF . "tpl/$tpl/style.ini";
  54. $cache_files[] = __FILE__;
  55. if ($INPUT->bool('preview')) {
  56. $cache_files[] = $conf['cachedir'] . '/preview.ini';
  57. }
  58. // Array of needed files and their web locations, the latter ones
  59. // are needed to fix relative paths in the stylesheets
  60. $media_files = [];
  61. foreach ($mediatypes as $mediatype) {
  62. $files = [];
  63. // load core styles
  64. $files[DOKU_INC . 'lib/styles/' . $mediatype . '.css'] = DOKU_BASE . 'lib/styles/';
  65. // load jQuery-UI theme
  66. if ($mediatype == 'screen') {
  67. $files[DOKU_INC . 'lib/scripts/jquery/jquery-ui-theme/smoothness.css'] =
  68. DOKU_BASE . 'lib/scripts/jquery/jquery-ui-theme/';
  69. }
  70. // load plugin styles
  71. $files = array_merge($files, css_pluginstyles($mediatype));
  72. // load template styles
  73. if (isset($styleini['stylesheets'][$mediatype])) {
  74. $files = array_merge($files, $styleini['stylesheets'][$mediatype]);
  75. }
  76. // load user styles
  77. if (isset($config_cascade['userstyle'][$mediatype]) && is_array($config_cascade['userstyle'][$mediatype])) {
  78. foreach ($config_cascade['userstyle'][$mediatype] as $userstyle) {
  79. $files[$userstyle] = DOKU_BASE;
  80. }
  81. }
  82. // Let plugins decide to either put more styles here or to remove some
  83. $media_files[$mediatype] = css_filewrapper($mediatype, $files);
  84. $CSSEvt = new Event('CSS_STYLES_INCLUDED', $media_files[$mediatype]);
  85. // Make it preventable.
  86. if ($CSSEvt->advise_before()) {
  87. $cache_files = array_merge($cache_files, array_keys($media_files[$mediatype]['files']));
  88. } else {
  89. // unset if prevented. Nothing will be printed for this mediatype.
  90. unset($media_files[$mediatype]);
  91. }
  92. // finish event.
  93. $CSSEvt->advise_after();
  94. }
  95. // The generated script depends on some dynamic options
  96. $cache = new Cache(
  97. 'styles' .
  98. $_SERVER['HTTP_HOST'] .
  99. $_SERVER['SERVER_PORT'] .
  100. $INPUT->bool('preview') .
  101. DOKU_BASE .
  102. $tpl .
  103. $type,
  104. '.css'
  105. );
  106. $cache->setEvent('CSS_CACHE_USE');
  107. // check cache age & handle conditional request
  108. // This may exit if a cache can be used
  109. $cache_ok = $cache->useCache(['files' => $cache_files]);
  110. http_cached($cache->cache, $cache_ok);
  111. // start output buffering
  112. ob_start();
  113. // Fire CSS_STYLES_INCLUDED for one last time to let the
  114. // plugins decide whether to include the DW default styles.
  115. // This can be done by preventing the Default.
  116. $media_files['DW_DEFAULT'] = css_filewrapper('DW_DEFAULT');
  117. Event::createAndTrigger('CSS_STYLES_INCLUDED', $media_files['DW_DEFAULT'], 'css_defaultstyles');
  118. // build the stylesheet
  119. foreach ($mediatypes as $mediatype) {
  120. // Check if there is a wrapper set for this type.
  121. if (!isset($media_files[$mediatype])) {
  122. continue;
  123. }
  124. $cssData = $media_files[$mediatype];
  125. // Print the styles.
  126. echo NL;
  127. if ($cssData['encapsulate'] === true) {
  128. echo $cssData['encapsulationPrefix'] . ' {';
  129. }
  130. echo '/* START ' . $cssData['mediatype'] . ' styles */' . NL;
  131. // load files
  132. foreach ($cssData['files'] as $file => $location) {
  133. $display = str_replace(fullpath(DOKU_INC), '', fullpath($file));
  134. echo "\n/* XXXXXXXXX $display XXXXXXXXX */\n";
  135. echo css_loadfile($file, $location);
  136. }
  137. echo NL;
  138. if ($cssData['encapsulate'] === true) {
  139. echo '} /* /@media ';
  140. } else {
  141. echo '/*';
  142. }
  143. echo ' END ' . $cssData['mediatype'] . ' styles */' . NL;
  144. }
  145. // end output buffering and get contents
  146. $css = ob_get_contents();
  147. ob_end_clean();
  148. // strip any source maps
  149. stripsourcemaps($css);
  150. // apply style replacements
  151. $css = css_applystyle($css, $styleini['replacements']);
  152. // parse less
  153. $css = css_parseless($css);
  154. // compress whitespace and comments
  155. if ($conf['compress']) {
  156. $css = css_compress($css);
  157. }
  158. // embed small images right into the stylesheet
  159. if ($conf['cssdatauri']) {
  160. $base = preg_quote(DOKU_BASE, '#');
  161. $css = preg_replace_callback('#(url\([ \'"]*)(' . $base . ')(.*?(?:\.(png|gif)))#i', 'css_datauri', $css);
  162. }
  163. http_cached_finish($cache->cache, $css);
  164. }
  165. /**
  166. * Uses phpless to parse LESS in our CSS
  167. *
  168. * most of this function is error handling to show a nice useful error when
  169. * LESS compilation fails
  170. *
  171. * @param string $css
  172. * @return string
  173. */
  174. function css_parseless($css)
  175. {
  176. global $conf;
  177. $less = new Lessc();
  178. $less->setImportDir([DOKU_INC]);
  179. $less->setPreserveComments(!$conf['compress']);
  180. if (defined('DOKU_UNITTEST')) {
  181. $less->addImportDir(TMP_DIR);
  182. }
  183. try {
  184. return $less->compile($css);
  185. } catch (Exception $e) {
  186. // get exception message
  187. $msg = str_replace(["\n", "\r", "'"], [], $e->getMessage());
  188. // try to use line number to find affected file
  189. if (preg_match('/line: (\d+)$/', $msg, $m)) {
  190. $msg = substr($msg, 0, -1 * strlen($m[0])); //remove useless linenumber
  191. $lno = $m[1];
  192. // walk upwards to last include
  193. $lines = explode("\n", $css);
  194. for ($i = $lno - 1; $i >= 0; $i--) {
  195. if (preg_match('/\/(\* XXXXXXXXX )(.*?)( XXXXXXXXX \*)\//', $lines[$i], $m)) {
  196. // we found it, add info to message
  197. $msg .= ' in ' . $m[2] . ' at line ' . ($lno - $i);
  198. break;
  199. }
  200. }
  201. }
  202. // something went wrong
  203. $error = 'A fatal error occured during compilation of the CSS files. ' .
  204. 'If you recently installed a new plugin or template it ' .
  205. 'might be broken and you should try disabling it again. [' . $msg . ']';
  206. echo ".dokuwiki:before {
  207. content: '$error';
  208. background-color: red;
  209. display: block;
  210. background-color: #fcc;
  211. border-color: #ebb;
  212. color: #000;
  213. padding: 0.5em;
  214. }";
  215. exit;
  216. }
  217. }
  218. /**
  219. * Does placeholder replacements in the style according to
  220. * the ones defined in a templates style.ini file
  221. *
  222. * This also adds the ini defined placeholders as less variables
  223. * (sans the surrounding __ and with a ini_ prefix)
  224. *
  225. * @param string $css
  226. * @param array $replacements array(placeholder => value)
  227. * @return string
  228. *
  229. * @author Andreas Gohr <andi@splitbrain.org>
  230. */
  231. function css_applystyle($css, $replacements)
  232. {
  233. // we convert ini replacements to LESS variable names
  234. // and build a list of variable: value; pairs
  235. $less = '';
  236. foreach ((array)$replacements as $key => $value) {
  237. $lkey = trim($key, '_');
  238. $lkey = '@ini_' . $lkey;
  239. $less .= "$lkey: $value;\n";
  240. $replacements[$key] = $lkey;
  241. }
  242. // we now replace all old ini replacements with LESS variables
  243. $css = strtr($css, $replacements);
  244. // now prepend the list of LESS variables as the very first thing
  245. $css = $less . $css;
  246. return $css;
  247. }
  248. /**
  249. * Wrapper for the files, content and mediatype for the event CSS_STYLES_INCLUDED
  250. *
  251. * @param string $mediatype type ofthe current media files/content set
  252. * @param array $files set of files that define the current mediatype
  253. * @return array
  254. *
  255. * @author Gerry Weißbach <gerry.w@gammaproduction.de>
  256. */
  257. function css_filewrapper($mediatype, $files = [])
  258. {
  259. return [
  260. 'files' => $files,
  261. 'mediatype' => $mediatype,
  262. 'encapsulate' => $mediatype != 'all',
  263. 'encapsulationPrefix' => '@media ' . $mediatype
  264. ];
  265. }
  266. /**
  267. * Prints the @media encapsulated default styles of DokuWiki
  268. *
  269. * This function is being called by a CSS_STYLES_INCLUDED event
  270. * The event can be distinguished by the mediatype which is:
  271. * DW_DEFAULT
  272. *
  273. * @author Gerry Weißbach <gerry.w@gammaproduction.de>
  274. */
  275. function css_defaultstyles()
  276. {
  277. // print the default classes for interwiki links and file downloads
  278. echo '@media screen {';
  279. css_interwiki();
  280. css_filetypes();
  281. echo '}';
  282. }
  283. /**
  284. * Prints classes for interwikilinks
  285. *
  286. * Interwiki links have two classes: 'interwiki' and 'iw_$name>' where
  287. * $name is the identifier given in the config. All Interwiki links get
  288. * an default style with a default icon. If a special icon is available
  289. * for an interwiki URL it is set in it's own class. Both classes can be
  290. * overwritten in the template or userstyles.
  291. *
  292. * @author Andreas Gohr <andi@splitbrain.org>
  293. */
  294. function css_interwiki()
  295. {
  296. // default style
  297. echo 'a.interwiki {';
  298. echo ' background: transparent url(' . DOKU_BASE . 'lib/images/interwiki.svg) 0 0 no-repeat;';
  299. echo ' background-size: 1.2em;';
  300. echo ' padding: 0 0 0 1.4em;';
  301. echo '}';
  302. // additional styles when icon available
  303. $iwlinks = getInterwiki();
  304. foreach (array_keys($iwlinks) as $iw) {
  305. $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $iw);
  306. foreach (['svg', 'png', 'gif'] as $ext) {
  307. $file = 'lib/images/interwiki/' . $iw . '.' . $ext;
  308. if (file_exists(DOKU_INC . $file)) {
  309. echo "a.iw_$class {";
  310. echo ' background-image: url(' . DOKU_BASE . $file . ')';
  311. echo '}';
  312. break;
  313. }
  314. }
  315. }
  316. }
  317. /**
  318. * Prints classes for file download links
  319. *
  320. * @author Andreas Gohr <andi@splitbrain.org>
  321. */
  322. function css_filetypes()
  323. {
  324. // default style
  325. echo '.mediafile {';
  326. echo ' background: transparent url(' . DOKU_BASE . 'lib/images/fileicons/svg/file.svg) 0px 1px no-repeat;';
  327. echo ' background-size: 1.2em;';
  328. echo ' padding-left: 1.5em;';
  329. echo '}';
  330. // additional styles when icon available
  331. // scan directory for all icons
  332. $exts = [];
  333. if ($dh = opendir(DOKU_INC . 'lib/images/fileicons/svg')) {
  334. while (false !== ($file = readdir($dh))) {
  335. if (preg_match('/(.*?)\.svg$/i', $file, $match)) {
  336. $exts[] = strtolower($match[1]);
  337. }
  338. }
  339. closedir($dh);
  340. }
  341. foreach ($exts as $ext) {
  342. $class = preg_replace('/[^_\-a-z0-9]+/', '_', $ext);
  343. echo ".mf_$class {";
  344. echo ' background-image: url(' . DOKU_BASE . 'lib/images/fileicons/svg/' . $ext . '.svg)';
  345. echo '}';
  346. }
  347. }
  348. /**
  349. * Loads a given file and fixes relative URLs with the
  350. * given location prefix
  351. *
  352. * @param string $file file system path
  353. * @param string $location
  354. * @return string
  355. */
  356. function css_loadfile($file, $location = '')
  357. {
  358. $css_file = new DokuCssFile($file);
  359. return $css_file->load($location);
  360. }
  361. /**
  362. * Helper class to abstract loading of css/less files
  363. *
  364. * @author Chris Smith <chris@jalakai.co.uk>
  365. */
  366. class DokuCssFile
  367. {
  368. protected $filepath; // file system path to the CSS/Less file
  369. protected $location; // base url location of the CSS/Less file
  370. protected $relative_path;
  371. public function __construct($file)
  372. {
  373. $this->filepath = $file;
  374. }
  375. /**
  376. * Load the contents of the css/less file and adjust any relative paths/urls (relative to this file) to be
  377. * relative to the dokuwiki root: the web root (DOKU_BASE) for most files; the file system root (DOKU_INC)
  378. * for less files.
  379. *
  380. * @param string $location base url for this file
  381. * @return string the CSS/Less contents of the file
  382. */
  383. public function load($location = '')
  384. {
  385. if (!file_exists($this->filepath)) return '';
  386. $css = io_readFile($this->filepath);
  387. if (!$location) return $css;
  388. $this->location = $location;
  389. $css = preg_replace_callback('#(url\( *)([\'"]?)(.*?)(\2)( *\))#', [$this, 'replacements'], $css);
  390. $css = preg_replace_callback('#(@import\s+)([\'"])(.*?)(\2)#', [$this, 'replacements'], $css);
  391. return $css;
  392. }
  393. /**
  394. * Get the relative file system path of this file, relative to dokuwiki's root folder, DOKU_INC
  395. *
  396. * @return string relative file system path
  397. */
  398. protected function getRelativePath()
  399. {
  400. if (is_null($this->relative_path)) {
  401. $basedir = [DOKU_INC];
  402. // during testing, files may be found relative to a second base dir, TMP_DIR
  403. if (defined('DOKU_UNITTEST')) {
  404. $basedir[] = realpath(TMP_DIR);
  405. }
  406. $basedir = array_map('preg_quote_cb', $basedir);
  407. $regex = '/^(' . implode('|', $basedir) . ')/';
  408. $this->relative_path = preg_replace($regex, '', dirname($this->filepath));
  409. }
  410. return $this->relative_path;
  411. }
  412. /**
  413. * preg_replace callback to adjust relative urls from relative to this file to relative
  414. * to the appropriate dokuwiki root location as described in the code
  415. *
  416. * @param array $match see http://php.net/preg_replace_callback
  417. * @return string see http://php.net/preg_replace_callback
  418. */
  419. public function replacements($match)
  420. {
  421. if (preg_match('#^(/|data:|https?://)#', $match[3])) { // not a relative url? - no adjustment required
  422. return $match[0];
  423. } elseif (str_ends_with($match[3], '.less')) { // a less file import? - requires a file system location
  424. if ($match[3][0] != '/') {
  425. $match[3] = $this->getRelativePath() . '/' . $match[3];
  426. }
  427. } else { // everything else requires a url adjustment
  428. $match[3] = $this->location . $match[3];
  429. }
  430. return implode('', array_slice($match, 1));
  431. }
  432. }
  433. /**
  434. * Convert local image URLs to data URLs if the filesize is small
  435. *
  436. * Callback for preg_replace_callback
  437. *
  438. * @param array $match
  439. * @return string
  440. */
  441. function css_datauri($match)
  442. {
  443. global $conf;
  444. $pre = unslash($match[1]);
  445. $base = unslash($match[2]);
  446. $url = unslash($match[3]);
  447. $ext = unslash($match[4]);
  448. $local = DOKU_INC . $url;
  449. $size = @filesize($local);
  450. if ($size && $size < $conf['cssdatauri']) {
  451. $data = base64_encode(file_get_contents($local));
  452. }
  453. if (!empty($data)) {
  454. $url = 'data:image/' . $ext . ';base64,' . $data;
  455. } else {
  456. $url = $base . $url;
  457. }
  458. return $pre . $url;
  459. }
  460. /**
  461. * Returns a list of possible Plugin Styles (no existance check here)
  462. *
  463. * @param string $mediatype
  464. * @return array
  465. * @author Andreas Gohr <andi@splitbrain.org>
  466. *
  467. */
  468. function css_pluginstyles($mediatype = 'screen')
  469. {
  470. $list = [];
  471. $plugins = plugin_list();
  472. foreach ($plugins as $p) {
  473. $list[DOKU_PLUGIN . "$p/$mediatype.css"] = DOKU_BASE . "lib/plugins/$p/";
  474. $list[DOKU_PLUGIN . "$p/$mediatype.less"] = DOKU_BASE . "lib/plugins/$p/";
  475. // alternative for screen.css
  476. if ($mediatype == 'screen') {
  477. $list[DOKU_PLUGIN . "$p/style.css"] = DOKU_BASE . "lib/plugins/$p/";
  478. $list[DOKU_PLUGIN . "$p/style.less"] = DOKU_BASE . "lib/plugins/$p/";
  479. }
  480. }
  481. return $list;
  482. }
  483. /**
  484. * Very simple CSS optimizer
  485. *
  486. * @param string $css
  487. * @return string
  488. * @author Andreas Gohr <andi@splitbrain.org>
  489. *
  490. */
  491. function css_compress($css)
  492. {
  493. // replace quoted strings with placeholder
  494. $quote_storage = [];
  495. $quote_cb = function ($match) use (&$quote_storage) {
  496. $quote_storage[] = $match[0];
  497. return '"STR' . (count($quote_storage) - 1) . '"';
  498. };
  499. $css = preg_replace_callback('/(([\'"]).*?(?<!\\\\)\2)/', $quote_cb, $css);
  500. // strip comments through a callback
  501. $css = preg_replace_callback('#(/\*)(.*?)(\*/)#s', 'css_comment_cb', $css);
  502. // strip (incorrect but common) one line comments
  503. $css = preg_replace_callback('/^.*\/\/.*$/m', 'css_onelinecomment_cb', $css);
  504. // strip whitespaces
  505. $css = preg_replace('![\r\n\t ]+!', ' ', $css);
  506. $css = preg_replace('/ ?([;,{}\/]) ?/', '\\1', $css);
  507. $css = preg_replace('/ ?: /', ':', $css);
  508. // number compression
  509. $css = preg_replace(
  510. '/([: ])0+(\.\d+?)0*((?:pt|pc|in|mm|cm|em|ex|px)\b|%)(?=[^\{]*[;\}])/',
  511. '$1$2$3',
  512. $css
  513. ); // "0.1em" to ".1em", "1.10em" to "1.1em"
  514. $css = preg_replace(
  515. '/([: ])\.(0)+((?:pt|pc|in|mm|cm|em|ex|px)\b|%)(?=[^\{]*[;\}])/',
  516. '$1$2',
  517. $css
  518. ); // ".0em" to "0"
  519. $css = preg_replace(
  520. '/([: ]0)0*(\.0*)?((?:pt|pc|in|mm|cm|em|ex|px)(?=[^\{]*[;\}])\b|%)/',
  521. '$1',
  522. $css
  523. ); // "0.0em" to "0"
  524. $css = preg_replace(
  525. '/([: ]\d+)(\.0*)((?:pt|pc|in|mm|cm|em|ex|px)(?=[^\{]*[;\}])\b|%)/',
  526. '$1$3',
  527. $css
  528. ); // "1.0em" to "1em"
  529. $css = preg_replace(
  530. '/([: ])0+(\d+|\d*\.\d+)((?:pt|pc|in|mm|cm|em|ex|px)(?=[^\{]*[;\}])\b|%)/',
  531. '$1$2$3',
  532. $css
  533. ); // "001em" to "1em"
  534. // shorten attributes (1em 1em 1em 1em -> 1em)
  535. $css = preg_replace(
  536. '/(?<![\w\-])((?:margin|padding|border|border-(?:width|radius)):)([\w\.]+)( \2)+(?=[;\}]| !)/',
  537. '$1$2',
  538. $css
  539. ); // "1em 1em 1em 1em" to "1em"
  540. $css = preg_replace(
  541. '/(?<![\w\-])((?:margin|padding|border|border-(?:width)):)([\w\.]+) ([\w\.]+) \2 \3(?=[;\}]| !)/',
  542. '$1$2 $3',
  543. $css
  544. ); // "1em 2em 1em 2em" to "1em 2em"
  545. // shorten colors
  546. $css = preg_replace(
  547. "/#([0-9a-fA-F]{1})\\1([0-9a-fA-F]{1})\\2([0-9a-fA-F]{1})\\3(?=[^\{]*[;\}])/",
  548. "#\\1\\2\\3",
  549. $css
  550. );
  551. // replace back protected strings
  552. $quote_back_cb = function ($match) use (&$quote_storage) {
  553. return $quote_storage[$match[1]];
  554. };
  555. $css = preg_replace_callback('/"STR(\d+)"/', $quote_back_cb, $css);
  556. $css = trim($css);
  557. return $css;
  558. }
  559. /**
  560. * Callback for css_compress()
  561. *
  562. * Keeps short comments (< 5 chars) to maintain typical browser hacks
  563. *
  564. * @param array $matches
  565. * @return string
  566. *
  567. * @author Andreas Gohr <andi@splitbrain.org>
  568. *
  569. */
  570. function css_comment_cb($matches)
  571. {
  572. if (strlen($matches[2]) > 4) return '';
  573. return $matches[0];
  574. }
  575. /**
  576. * Callback for css_compress()
  577. *
  578. * Strips one line comments but makes sure it will not destroy url() constructs with slashes
  579. *
  580. * @param array $matches
  581. * @return string
  582. */
  583. function css_onelinecomment_cb($matches)
  584. {
  585. $line = $matches[0];
  586. $i = 0;
  587. $len = strlen($line);
  588. while ($i < $len) {
  589. $nextcom = strpos($line, '//', $i);
  590. $nexturl = stripos($line, 'url(', $i);
  591. if ($nextcom === false) {
  592. // no more comments, we're done
  593. $i = $len;
  594. break;
  595. }
  596. if ($nexturl === false || $nextcom < $nexturl) {
  597. // no url anymore, strip comment and be done
  598. $i = $nextcom;
  599. break;
  600. }
  601. // we have an upcoming url
  602. $i = strpos($line, ')', $nexturl);
  603. }
  604. return substr($line, 0, $i);
  605. }
  606. //Setup VIM: ex: et ts=4 :