Repository: vuejs/vue-hackernews-2.0
Branch: master
Commit: 98399b55c6f1
Files: 34
Total size: 48.3 KB
Directory structure:
gitextract_ha5ywnnr/
├── .babelrc
├── .gitignore
├── LICENSE
├── README.md
├── build/
│ ├── setup-dev-server.js
│ ├── webpack.base.config.js
│ ├── webpack.client.config.js
│ └── webpack.server.config.js
├── manifest.json
├── package.json
├── server.js
└── src/
├── App.vue
├── api/
│ ├── create-api-client.js
│ ├── create-api-server.js
│ └── index.js
├── app.js
├── components/
│ ├── Comment.vue
│ ├── Item.vue
│ ├── ProgressBar.vue
│ └── Spinner.vue
├── entry-client.js
├── entry-server.js
├── index.template.html
├── router/
│ └── index.js
├── store/
│ ├── actions.js
│ ├── getters.js
│ ├── index.js
│ └── mutations.js
├── util/
│ ├── filters.js
│ └── title.js
└── views/
├── CreateListView.js
├── ItemList.vue
├── ItemView.vue
└── UserView.vue
================================================
FILE CONTENTS
================================================
================================================
FILE: .babelrc
================================================
{
"presets": [
["env", { "modules": false }]
],
"plugins": [
"syntax-dynamic-import"
]
}
================================================
FILE: .gitignore
================================================
.DS_Store
node_modules/
dist/
npm-debug.log
yarn-error.log
.idea
*.iml
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2013-present, Yuxi (Evan) You
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
# vue-hackernews-2.0
HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side rendering.
<p align="center">
<a href="https://vue-hn.herokuapp.com" target="_blank">
<img src="https://cloud.githubusercontent.com/assets/499550/17546273/5aabc5fc-5eaf-11e6-8d6a-ad00937e8bd6.png" width="700px">
<br>
Live Demo
</a>
</p>
## Features
> Note: in practice, it is unnecessary to code-split for an app of this size (where each async chunk is only a few kilobytes), nor is it optimal to extract an extra CSS file (which is only 1kb) -- they are used simply because this is a demo app showcasing all the supported features.
- Server Side Rendering
- Vue + vue-router + vuex working together
- Server-side data pre-fetching
- Client-side state & DOM hydration
- Automatically inlines CSS used by rendered components only
- Preload / prefetch resource hints
- Route-level code splitting
- Progressive Web App
- App manifest
- Service worker
- 100/100 Lighthouse score
- Single-file Vue Components
- Hot-reload in development
- CSS extraction for production
- Animation
- Effects when switching route views
- Real-time list updates with FLIP Animation
## A Note on Performance
This is a demo primarily aimed at explaining how to build a server-side rendered Vue app, as a companion to our SSR documentation. There are a few things we probably won't do in production if we were optimizing for performance, for example:
- This demo uses the Firebase-based HN API to showcase real-time updates, but the Firebase API also comes with a larger bundle, more JavaScript to parse on the client, and doesn't offer an efficient way to batch-fetch pages of items, so it impacts performance quite a bit on a cold start or cache miss.
- In practice, it is unnecessary to code-split for an app of this size (where each async chunk is only a few kilobytes so the extra request isn't really worth it), nor is it optimal to extract an extra CSS file (which is only 1kb).
It is therefore not recommended to use this app as a reference for Vue SSR performance - instead, do your own benchmarking, and make sure to measure and optimize based on your actual app constraints.
## Architecture Overview
<img width="973" alt="screen shot 2016-08-11 at 6 06 57 pm" src="https://cloud.githubusercontent.com/assets/499550/17607895/786a415a-5fee-11e6-9c11-45a2cfdf085c.png">
**A detailed Vue SSR guide can be found [here](https://ssr.vuejs.org).**
## Build Setup
**Requires Node.js 7+**
``` bash
# install dependencies
npm install # or yarn
# serve in dev mode, with hot reload at localhost:8080
npm run dev
# build for production
npm run build
# serve in production mode
npm start
```
## License
MIT
================================================
FILE: build/setup-dev-server.js
================================================
const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')
const readFile = (fs, file) => {
try {
return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
} catch (e) {}
}
module.exports = function setupDevServer (app, templatePath, cb) {
let bundle
let template
let clientManifest
let ready
const readyPromise = new Promise(r => { ready = r })
const update = () => {
if (bundle && clientManifest) {
ready()
cb(bundle, {
template,
clientManifest
})
}
}
// read template from disk and watch
template = fs.readFileSync(templatePath, 'utf-8')
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
console.log('index.html template updated.')
update()
})
// modify client config to work with hot middleware
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
clientConfig.output.filename = '[name].js'
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
)
// dev middleware
const clientCompiler = webpack(clientConfig)
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true
})
app.use(devMiddleware)
clientCompiler.plugin('done', stats => {
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
if (stats.errors.length) return
clientManifest = JSON.parse(readFile(
devMiddleware.fileSystem,
'vue-ssr-client-manifest.json'
))
update()
})
// hot middleware
app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))
// watch and update server renderer
const serverCompiler = webpack(serverConfig)
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
if (stats.errors.length) return
// read bundle generated by vue-ssr-webpack-plugin
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
update()
})
return readyPromise
}
================================================
FILE: build/webpack.base.config.js
================================================
const path = require('path')
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
devtool: isProd
? false
: '#cheap-module-source-map',
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
filename: '[name].[chunkhash].js'
},
resolve: {
alias: {
'public': path.resolve(__dirname, '../public')
}
},
module: {
noParse: /es6-promise\.js$/, // avoid webpack shimming process
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'url-loader',
options: {
limit: 10000,
name: '[name].[ext]?[hash]'
}
},
{
test: /\.styl(us)?$/,
use: isProd
? ExtractTextPlugin.extract({
use: [
{
loader: 'css-loader',
options: { minimize: true }
},
'stylus-loader'
],
fallback: 'vue-style-loader'
})
: ['vue-style-loader', 'css-loader', 'stylus-loader']
},
]
},
performance: {
hints: false
},
plugins: isProd
? [
new VueLoaderPlugin(),
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false }
}),
new webpack.optimize.ModuleConcatenationPlugin(),
new ExtractTextPlugin({
filename: 'common.[chunkhash].css'
})
]
: [
new VueLoaderPlugin(),
new FriendlyErrorsPlugin()
]
}
================================================
FILE: build/webpack.client.config.js
================================================
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const SWPrecachePlugin = require('sw-precache-webpack-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = merge(base, {
entry: {
app: './src/entry-client.js'
},
resolve: {
alias: {
'create-api': './create-api-client.js'
}
},
plugins: [
// strip dev-only code in Vue source
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
// extract vendor chunks for better caching
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
// a module is extracted into the vendor chunk if...
return (
// it's inside node_modules
/node_modules/.test(module.context) &&
// and not a CSS file (due to extract-text-webpack-plugin limitation)
!/\.css$/.test(module.request)
)
}
}),
// extract webpack runtime & manifest to avoid vendor chunk hash changing
// on every build.
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
new VueSSRClientPlugin()
]
})
if (process.env.NODE_ENV === 'production') {
config.plugins.push(
// auto generate service worker
new SWPrecachePlugin({
cacheId: 'vue-hn',
filename: 'service-worker.js',
minify: true,
dontCacheBustUrlsMatching: /./,
staticFileGlobsIgnorePatterns: [/\.map$/, /\.json$/],
runtimeCaching: [
{
urlPattern: '/',
handler: 'networkFirst'
},
{
urlPattern: /\/(top|new|show|ask|jobs)/,
handler: 'networkFirst'
},
{
urlPattern: '/item/:id',
handler: 'networkFirst'
},
{
urlPattern: '/user/:id',
handler: 'networkFirst'
}
]
})
)
}
module.exports = config
================================================
FILE: build/webpack.server.config.js
================================================
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(base, {
target: 'node',
devtool: '#source-map',
entry: './src/entry-server.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
resolve: {
alias: {
'create-api': './create-api-server.js'
}
},
// https://webpack.js.org/configuration/externals/#externals
// https://github.com/liady/webpack-node-externals
externals: nodeExternals({
// do not externalize CSS files in case we need to import it from a dep
whitelist: /\.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
================================================
FILE: manifest.json
================================================
{
"name": "Vue Hackernews 2.0",
"short_name": "Vue HN",
"icons": [{
"src": "/public/logo-120.png",
"sizes": "120x120",
"type": "image/png"
}, {
"src": "/public/logo-144.png",
"sizes": "144x144",
"type": "image/png"
}, {
"src": "/public/logo-152.png",
"sizes": "152x152",
"type": "image/png"
}, {
"src": "/public/logo-192.png",
"sizes": "192x192",
"type": "image/png"
}, {
"src": "/public/logo-256.png",
"sizes": "256x256",
"type": "image/png"
}, {
"src": "/public/logo-384.png",
"sizes": "384x384",
"type": "image/png"
}, {
"src": "/public/logo-512.png",
"sizes": "512x512",
"type": "image/png"
}],
"start_url": "/",
"background_color": "#f2f3f5",
"display": "standalone",
"theme_color": "#f60"
}
================================================
FILE: package.json
================================================
{
"name": "vue-hackernews-2.0",
"description": "A Vue.js project",
"author": "Evan You <yyx990803@gmail.com>",
"private": true,
"scripts": {
"dev": "node server",
"start": "cross-env NODE_ENV=production node server",
"build": "rimraf dist && npm run build:client && npm run build:server",
"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules",
"postinstall": "npm run build"
},
"engines": {
"node": ">=7.0",
"npm": ">=4.0"
},
"dependencies": {
"compression": "^1.7.1",
"cross-env": "^5.1.1",
"es6-promise": "^4.1.1",
"express": "^4.16.2",
"extract-text-webpack-plugin": "^3.0.2",
"firebase": "4.6.2",
"lru-cache": "^4.1.1",
"route-cache": "0.4.3",
"serve-favicon": "^2.4.5",
"vue": "^2.5.22",
"vue-router": "^3.0.1",
"vue-server-renderer": "^2.5.22",
"vuex": "^3.0.1",
"vuex-router-sync": "^5.0.0"
},
"devDependencies": {
"autoprefixer": "^7.1.6",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-preset-env": "^1.6.1",
"chokidar": "^1.7.0",
"css-loader": "^0.28.7",
"file-loader": "^1.1.5",
"friendly-errors-webpack-plugin": "^1.6.1",
"rimraf": "^2.6.2",
"stylus": "^0.54.5",
"stylus-loader": "^3.0.1",
"sw-precache-webpack-plugin": "^0.11.4",
"url-loader": "^0.6.2",
"vue-loader": "^15.3.0",
"vue-template-compiler": "^2.5.22",
"webpack": "^3.8.1",
"webpack-dev-middleware": "^1.12.0",
"webpack-hot-middleware": "^2.20.0",
"webpack-merge": "^4.2.1",
"webpack-node-externals": "^1.7.2"
}
}
================================================
FILE: server.js
================================================
const fs = require('fs')
const path = require('path')
const LRU = require('lru-cache')
const express = require('express')
const favicon = require('serve-favicon')
const compression = require('compression')
const microcache = require('route-cache')
const resolve = file => path.resolve(__dirname, file)
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
const useMicroCache = process.env.MICRO_CACHE !== 'false'
const serverInfo =
`express/${require('express/package.json').version} ` +
`vue-server-renderer/${require('vue-server-renderer/package.json').version}`
const app = express()
function createRenderer (bundle, options) {
// https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
return createBundleRenderer(bundle, Object.assign(options, {
// for component caching
cache: LRU({
max: 1000,
maxAge: 1000 * 60 * 15
}),
// this is only needed when vue-server-renderer is npm-linked
basedir: resolve('./dist'),
// recommended for performance
runInNewContext: false
}))
}
let renderer
let readyPromise
const templatePath = resolve('./src/index.template.html')
if (isProd) {
// In production: create server renderer using template and built server bundle.
// The server bundle is generated by vue-ssr-webpack-plugin.
const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('./dist/vue-ssr-server-bundle.json')
// The client manifests are optional, but it allows the renderer
// to automatically infer preload/prefetch links and directly add <script>
// tags for any async chunks used during render, avoiding waterfall requests.
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
template,
clientManifest
})
} else {
// In development: setup the dev server with watch and hot-reload,
// and create a new renderer on bundle / index template update.
readyPromise = require('./build/setup-dev-server')(
app,
templatePath,
(bundle, options) => {
renderer = createRenderer(bundle, options)
}
)
}
const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
})
app.use(compression({ threshold: 0 }))
app.use(favicon('./public/logo-48.png'))
app.use('/dist', serve('./dist', true))
app.use('/public', serve('./public', true))
app.use('/manifest.json', serve('./manifest.json', true))
app.use('/service-worker.js', serve('./dist/service-worker.js'))
// since this app has no user-specific content, every page is micro-cacheable.
// if your app involves user-specific content, you need to implement custom
// logic to determine whether a request is cacheable based on its url and
// headers.
// 1-second microcache.
// https://www.nginx.com/blog/benefits-of-microcaching-nginx/
app.use(microcache.cacheSeconds(1, req => useMicroCache && req.originalUrl))
function render (req, res) {
const s = Date.now()
res.setHeader("Content-Type", "text/html")
res.setHeader("Server", serverInfo)
const handleError = err => {
if (err.url) {
res.redirect(err.url)
} else if(err.code === 404) {
res.status(404).send('404 | Page Not Found')
} else {
// Render Error Page or Redirect
res.status(500).send('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err.stack)
}
}
const context = {
title: 'Vue HN 2.0', // default title
url: req.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
res.send(html)
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
}
})
}
app.get('*', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res))
})
const port = process.env.PORT || 8080
app.listen(port, () => {
console.log(`server started at localhost:${port}`)
})
================================================
FILE: src/App.vue
================================================
<template>
<div id="app">
<header class="header">
<nav class="inner">
<router-link to="/" exact>
<img class="logo" src="~public/logo-48.png" alt="logo">
</router-link>
<router-link to="/top">Top</router-link>
<router-link to="/new">New</router-link>
<router-link to="/show">Show</router-link>
<router-link to="/ask">Ask</router-link>
<router-link to="/job">Jobs</router-link>
<a class="github" href="https://github.com/vuejs/vue-hackernews-2.0" target="_blank" rel="noopener">
Built with Vue.js
</a>
</nav>
</header>
<transition name="fade" mode="out-in">
<router-view class="view"></router-view>
</transition>
</div>
</template>
<style lang="stylus">
body
font-family -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-size 15px
background-color lighten(#eceef1, 30%)
margin 0
padding-top 55px
color #34495e
overflow-y scroll
a
color #34495e
text-decoration none
.header
background-color #ff6600
position fixed
z-index 999
height 55px
top 0
left 0
right 0
.inner
max-width 800px
box-sizing border-box
margin 0px auto
padding 15px 5px
a
color rgba(255, 255, 255, .8)
line-height 24px
transition color .15s ease
display inline-block
vertical-align middle
font-weight 300
letter-spacing .075em
margin-right 1.8em
&:hover
color #fff
&.router-link-active
color #fff
font-weight 400
&:nth-child(6)
margin-right 0
.github
color #fff
font-size .9em
margin 0
float right
.logo
width 24px
margin-right 10px
display inline-block
vertical-align middle
.view
max-width 800px
margin 0 auto
position relative
.fade-enter-active, .fade-leave-active
transition all .2s ease
.fade-enter, .fade-leave-active
opacity 0
@media (max-width 860px)
.header .inner
padding 15px 30px
@media (max-width 600px)
.header
.inner
padding 15px
a
margin-right 1em
.github
display none
</style>
================================================
FILE: src/api/create-api-client.js
================================================
import Firebase from 'firebase/app'
import 'firebase/database'
export function createAPI ({ config, version }) {
Firebase.initializeApp(config)
return Firebase.database().ref(version)
}
================================================
FILE: src/api/create-api-server.js
================================================
import Firebase from 'firebase'
import LRU from 'lru-cache'
export function createAPI ({ config, version }) {
let api
// this piece of code may run multiple times in development mode,
// so we attach the instantiated API to `process` to avoid duplications
if (process.__API__) {
api = process.__API__
} else {
Firebase.initializeApp(config)
api = process.__API__ = Firebase.database().ref(version)
api.onServer = true
// fetched item cache
api.cachedItems = LRU({
max: 1000,
maxAge: 1000 * 60 * 15 // 15 min cache
})
// cache the latest story ids
api.cachedIds = {}
;['top', 'new', 'show', 'ask', 'job'].forEach(type => {
api.child(`${type}stories`).on('value', snapshot => {
api.cachedIds[type] = snapshot.val()
})
})
}
return api
}
================================================
FILE: src/api/index.js
================================================
// this is aliased in webpack config based on server/client build
import { createAPI } from 'create-api'
const logRequests = !!process.env.DEBUG_API
const api = createAPI({
version: '/v0',
config: {
databaseURL: 'https://hacker-news.firebaseio.com'
}
})
// warm the front page cache every 15 min
// make sure to do this only once across all requests
if (api.onServer) {
warmCache()
}
function warmCache () {
fetchItems((api.cachedIds.top || []).slice(0, 30))
setTimeout(warmCache, 1000 * 60 * 15)
}
function fetch (child) {
logRequests && console.log(`fetching ${child}...`)
const cache = api.cachedItems
if (cache && cache.has(child)) {
logRequests && console.log(`cache hit for ${child}.`)
return Promise.resolve(cache.get(child))
} else {
return new Promise((resolve, reject) => {
api.child(child).once('value', snapshot => {
const val = snapshot.val()
// mark the timestamp when this item is cached
if (val) val.__lastUpdated = Date.now()
cache && cache.set(child, val)
logRequests && console.log(`fetched ${child}.`)
resolve(val)
}, reject)
})
}
}
export function fetchIdsByType (type) {
return api.cachedIds && api.cachedIds[type]
? Promise.resolve(api.cachedIds[type])
: fetch(`${type}stories`)
}
export function fetchItem (id) {
return fetch(`item/${id}`)
}
export function fetchItems (ids) {
return Promise.all(ids.map(id => fetchItem(id)))
}
export function fetchUser (id) {
return fetch(`user/${id}`)
}
export function watchList (type, cb) {
let first = true
const ref = api.child(`${type}stories`)
const handler = snapshot => {
if (first) {
first = false
} else {
cb(snapshot.val())
}
}
ref.on('value', handler)
return () => {
ref.off('value', handler)
}
}
================================================
FILE: src/app.js
================================================
import Vue from 'vue'
import App from './App.vue'
import { createStore } from './store'
import { createRouter } from './router'
import { sync } from 'vuex-router-sync'
import titleMixin from './util/title'
import * as filters from './util/filters'
// mixin for handling title
Vue.mixin(titleMixin)
// register global utility filters.
Object.keys(filters).forEach(key => {
Vue.filter(key, filters[key])
})
// Expose a factory function that creates a fresh set of store, router,
// app instances on each call (which is called for each SSR request)
export function createApp () {
// create store and router instances
const store = createStore()
const router = createRouter()
// sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router)
// create the app instance.
// here we inject the router, store and ssr context to all child components,
// making them available everywhere as `this.$router` and `this.$store`.
const app = new Vue({
router,
store,
render: h => h(App)
})
// expose the app, the router and the store.
// note we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return { app, router, store }
}
================================================
FILE: src/components/Comment.vue
================================================
<template>
<li v-if="comment" class="comment">
<div class="by">
<router-link :to="'/user/' + comment.by">{{ comment.by }}</router-link>
{{ comment.time | timeAgo }} ago
</div>
<div class="text" v-html="comment.text"></div>
<div class="toggle" :class="{ open }" v-if="comment.kids && comment.kids.length">
<a @click="open = !open">{{
open
? '[-]'
: '[+] ' + pluralize(comment.kids.length) + ' collapsed'
}}</a>
</div>
<ul class="comment-children" v-show="open">
<comment v-for="id in comment.kids" :key="id" :id="id"></comment>
</ul>
</li>
</template>
<script>
export default {
name: 'comment',
props: ['id'],
data () {
return {
open: true
}
},
computed: {
comment () {
return this.$store.state.items[this.id]
}
},
methods: {
pluralize: n => n + (n === 1 ? ' reply' : ' replies')
}
}
</script>
<style lang="stylus">
.comment-children
.comment-children
margin-left 1.5em
.comment
border-top 1px solid #eee
position relative
.by, .text, .toggle
font-size .9em
margin 1em 0
.by
color #828282
a
color #828282
text-decoration underline
.text
overflow-wrap break-word
a:hover
color #ff6600
pre
white-space pre-wrap
.toggle
background-color #fffbf2
padding .3em .5em
border-radius 4px
a
color #828282
cursor pointer
&.open
padding 0
background-color transparent
margin-bottom -0.5em
</style>
================================================
FILE: src/components/Item.vue
================================================
<template>
<li class="news-item">
<span class="score">{{ item.score }}</span>
<span class="title">
<template v-if="item.url">
<a :href="item.url" target="_blank" rel="noopener">{{ item.title }}</a>
<span class="host"> ({{ item.url | host }})</span>
</template>
<template v-else>
<router-link :to="'/item/' + item.id">{{ item.title }}</router-link>
</template>
</span>
<br>
<span class="meta">
<span v-if="item.type !== 'job'" class="by">
by <router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
</span>
<span class="time">
{{ item.time | timeAgo }} ago
</span>
<span v-if="item.type !== 'job'" class="comments-link">
| <router-link :to="'/item/' + item.id">{{ item.descendants }} comments</router-link>
</span>
</span>
<span class="label" v-if="item.type !== 'story'">{{ item.type }}</span>
</li>
</template>
<script>
import { timeAgo } from '../util/filters'
export default {
name: 'news-item',
props: ['item'],
// http://ssr.vuejs.org/en/caching.html#component-level-caching
serverCacheKey: ({ item: { id, __lastUpdated, time }}) => {
return `${id}::${__lastUpdated}::${timeAgo(time)}`
}
}
</script>
<style lang="stylus">
.news-item
background-color #fff
padding 20px 30px 20px 80px
border-bottom 1px solid #eee
position relative
line-height 20px
.score
color #ff6600
font-size 1.1em
font-weight 700
position absolute
top 50%
left 0
width 80px
text-align center
margin-top -10px
.meta, .host
font-size .85em
color #828282
a
color #828282
text-decoration underline
&:hover
color #ff6600
</style>
================================================
FILE: src/components/ProgressBar.vue
================================================
<!-- borrowed from Nuxt! -->
<template>
<div class="progress" :style="{
'width': percent+'%',
'height': height,
'background-color': canSuccess? color : failedColor,
'opacity': show ? 1 : 0
}"></div>
</template>
<script>
export default {
data () {
return {
percent: 0,
show: false,
canSuccess: true,
duration: 3000,
height: '2px',
color: '#ffca2b',
failedColor: '#ff0000',
}
},
methods: {
start () {
this.show = true
this.canSuccess = true
if (this._timer) {
clearInterval(this._timer)
this.percent = 0
}
this._cut = 10000 / Math.floor(this.duration)
this._timer = setInterval(() => {
this.increase(this._cut * Math.random())
if (this.percent > 95) {
this.finish()
}
}, 100)
return this
},
set (num) {
this.show = true
this.canSuccess = true
this.percent = Math.floor(num)
return this
},
get () {
return Math.floor(this.percent)
},
increase (num) {
this.percent = this.percent + Math.floor(num)
return this
},
decrease (num) {
this.percent = this.percent - Math.floor(num)
return this
},
finish () {
this.percent = 100
this.hide()
return this
},
pause () {
clearInterval(this._timer)
return this
},
hide () {
clearInterval(this._timer)
this._timer = null
setTimeout(() => {
this.show = false
this.$nextTick(() => {
setTimeout(() => {
this.percent = 0
}, 200)
})
}, 500)
return this
},
fail () {
this.canSuccess = false
return this
}
}
}
</script>
<style lang="stylus" scoped>
.progress
position: fixed
top: 0px
left: 0px
right: 0px
height: 2px
width: 0%
transition: width 0.2s, opacity 0.4s
opacity: 1
background-color: #efc14e
z-index: 999999
</style>
================================================
FILE: src/components/Spinner.vue
================================================
<template>
<transition>
<svg class="spinner" :class="{ show: show }" v-show="show" width="44px" height="44px" viewBox="0 0 44 44">
<circle class="path" fill="none" stroke-width="4" stroke-linecap="round" cx="22" cy="22" r="20"></circle>
</svg>
</transition>
</template>
<script>
export default {
name: 'spinner',
props: ['show'],
serverCacheKey: props => props.show
}
</script>
<style lang="stylus">
$offset = 126
$duration = 1.4s
.spinner
transition opacity .15s ease
animation rotator $duration linear infinite
animation-play-state paused
&.show
animation-play-state running
&.v-enter, &.v-leave-active
opacity 0
&.v-enter-active, &.v-leave
opacity 1
@keyframes rotator
0%
transform scale(0.5) rotate(0deg)
100%
transform scale(0.5) rotate(270deg)
.spinner .path
stroke #ff6600
stroke-dasharray $offset
stroke-dashoffset 0
transform-origin center
animation dash $duration ease-in-out infinite
@keyframes dash
0%
stroke-dashoffset $offset
50%
stroke-dashoffset ($offset/2)
transform rotate(135deg)
100%
stroke-dashoffset $offset
transform rotate(450deg)
</style>
================================================
FILE: src/entry-client.js
================================================
import Vue from 'vue'
import 'es6-promise/auto'
import { createApp } from './app'
import ProgressBar from './components/ProgressBar.vue'
// global progress bar
const bar = Vue.prototype.$bar = new Vue(ProgressBar).$mount()
document.body.appendChild(bar.$el)
// a global mixin that calls `asyncData` when a route component's params change
Vue.mixin({
beforeRouteUpdate (to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
})
const { app, router, store } = createApp()
// prime the store with server-initialized state.
// the state is determined during SSR and inlined in the page markup.
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// wait until router has resolved all async before hooks
// and async components...
router.onReady(() => {
// Add router hook for handling asyncData.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
let diffed = false
const activated = matched.filter((c, i) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
if (!asyncDataHooks.length) {
return next()
}
bar.start()
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
.then(() => {
bar.finish()
next()
})
.catch(next)
})
// actually mount to DOM
app.$mount('#app')
})
// service worker
if ('https:' === location.protocol && navigator.serviceWorker) {
navigator.serviceWorker.register('/service-worker.js')
}
================================================
FILE: src/entry-server.js
================================================
import { createApp } from './app'
const isDev = process.env.NODE_ENV !== 'production'
// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default context => {
return new Promise((resolve, reject) => {
const s = isDev && Date.now()
const { app, router, store } = createApp()
const { url } = context
const { fullPath } = router.resolve(url).route
if (fullPath !== url) {
return reject({ url: fullPath })
}
// set router's location
router.push(url)
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// no matched routes
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// Call fetchData hooks on components matched by the route.
// A preFetch hook dispatches a store action and returns a Promise,
// which is resolved when the action is complete and store state has been
// updated.
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
store,
route: router.currentRoute
}))).then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
// After all preFetch hooks are resolved, our store is now
// filled with the state needed to render the app.
// Expose the state on the render context, and let the request handler
// inline the state in the HTML response. This allows the client-side
// store to pick-up the server-side state without having to duplicate
// the initial data fetching on the client.
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
================================================
FILE: src/index.template.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ title }}</title>
<meta charset="utf-8">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" sizes="120x120" href="/public/logo-120.png">
<meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
<link rel="shortcut icon" sizes="48x48" href="/public/logo-48.png">
<meta name="theme-color" content="#f60">
<link rel="manifest" href="/manifest.json">
<style>
#skip a { position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden; }
#skip a:focus { position:static; width:auto; height:auto; }
</style>
</head>
<body>
<div id="skip"><a href="#app">skip to content</a></div>
<!--vue-ssr-outlet-->
</body>
</html>
================================================
FILE: src/router/index.js
================================================
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
// route-level code splitting
const createListView = id => () => import('../views/CreateListView').then(m => m.default(id))
const ItemView = () => import('../views/ItemView.vue')
const UserView = () => import('../views/UserView.vue')
export function createRouter () {
return new Router({
mode: 'history',
fallback: false,
scrollBehavior: () => ({ y: 0 }),
routes: [
{ path: '/top/:page(\\d+)?', component: createListView('top') },
{ path: '/new/:page(\\d+)?', component: createListView('new') },
{ path: '/show/:page(\\d+)?', component: createListView('show') },
{ path: '/ask/:page(\\d+)?', component: createListView('ask') },
{ path: '/job/:page(\\d+)?', component: createListView('job') },
{ path: '/item/:id(\\d+)', component: ItemView },
{ path: '/user/:id', component: UserView },
{ path: '/', redirect: '/top' }
]
})
}
================================================
FILE: src/store/actions.js
================================================
import {
fetchUser,
fetchItems,
fetchIdsByType
} from '../api'
export default {
// ensure data for rendering given list type
FETCH_LIST_DATA: ({ commit, dispatch, state }, { type }) => {
commit('SET_ACTIVE_TYPE', { type })
return fetchIdsByType(type)
.then(ids => commit('SET_LIST', { type, ids }))
.then(() => dispatch('ENSURE_ACTIVE_ITEMS'))
},
// ensure all active items are fetched
ENSURE_ACTIVE_ITEMS: ({ dispatch, getters }) => {
return dispatch('FETCH_ITEMS', {
ids: getters.activeIds
})
},
FETCH_ITEMS: ({ commit, state }, { ids }) => {
// on the client, the store itself serves as a cache.
// only fetch items that we do not already have, or has expired (3 minutes)
const now = Date.now()
ids = ids.filter(id => {
const item = state.items[id]
if (!item) {
return true
}
if (now - item.__lastUpdated > 1000 * 60 * 3) {
return true
}
return false
})
if (ids.length) {
return fetchItems(ids).then(items => commit('SET_ITEMS', { items }))
} else {
return Promise.resolve()
}
},
FETCH_USER: ({ commit, state }, { id }) => {
return state.users[id]
? Promise.resolve(state.users[id])
: fetchUser(id).then(user => commit('SET_USER', { id, user }))
}
}
================================================
FILE: src/store/getters.js
================================================
export default {
// ids of the items that should be currently displayed based on
// current list type and current pagination
activeIds (state) {
const { activeType, itemsPerPage, lists } = state
if (!activeType) {
return []
}
const page = Number(state.route.params.page) || 1
const start = (page - 1) * itemsPerPage
const end = page * itemsPerPage
return lists[activeType].slice(start, end)
},
// items that should be currently displayed.
// this Array may not be fully fetched.
activeItems (state, getters) {
return getters.activeIds.map(id => state.items[id]).filter(_ => _)
}
}
================================================
FILE: src/store/index.js
================================================
import Vue from 'vue'
import Vuex from 'vuex'
import actions from './actions'
import mutations from './mutations'
import getters from './getters'
Vue.use(Vuex)
export function createStore () {
return new Vuex.Store({
state: {
activeType: null,
itemsPerPage: 20,
items: {/* [id: number]: Item */},
users: {/* [id: string]: User */},
lists: {
top: [/* number */],
new: [],
show: [],
ask: [],
job: []
}
},
actions,
mutations,
getters
})
}
================================================
FILE: src/store/mutations.js
================================================
import Vue from 'vue'
export default {
SET_ACTIVE_TYPE: (state, { type }) => {
state.activeType = type
},
SET_LIST: (state, { type, ids }) => {
state.lists[type] = ids
},
SET_ITEMS: (state, { items }) => {
items.forEach(item => {
if (item) {
Vue.set(state.items, item.id, item)
}
})
},
SET_USER: (state, { id, user }) => {
Vue.set(state.users, id, user || false) /* false means user not found */
}
}
================================================
FILE: src/util/filters.js
================================================
export function host (url) {
const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
const parts = host.split('.').slice(-3)
if (parts[0] === 'www') parts.shift()
return parts.join('.')
}
export function timeAgo (time) {
const between = Date.now() / 1000 - Number(time)
if (between < 3600) {
return pluralize(~~(between / 60), ' minute')
} else if (between < 86400) {
return pluralize(~~(between / 3600), ' hour')
} else {
return pluralize(~~(between / 86400), ' day')
}
}
function pluralize (time, label) {
if (time === 1) {
return time + label
}
return time + label + 's'
}
================================================
FILE: src/util/title.js
================================================
function getTitle (vm) {
const { title } = vm.$options
if (title) {
return typeof title === 'function'
? title.call(vm)
: title
}
}
const serverTitleMixin = {
created () {
const title = getTitle(this)
if (title) {
this.$ssrContext.title = `Vue HN 2.0 | ${title}`
}
}
}
const clientTitleMixin = {
mounted () {
const title = getTitle(this)
if (title) {
document.title = `Vue HN 2.0 | ${title}`
}
}
}
export default process.env.VUE_ENV === 'server'
? serverTitleMixin
: clientTitleMixin
================================================
FILE: src/views/CreateListView.js
================================================
import ItemList from './ItemList.vue'
const camelize = str => str.charAt(0).toUpperCase() + str.slice(1)
// This is a factory function for dynamically creating root-level list views,
// since they share most of the logic except for the type of items to display.
// They are essentially higher order components wrapping ItemList.vue.
export default function createListView (type) {
return {
name: `${type}-stories-view`,
asyncData ({ store }) {
return store.dispatch('FETCH_LIST_DATA', { type })
},
title: camelize(type),
render (h) {
return h(ItemList, { props: { type }})
}
}
}
================================================
FILE: src/views/ItemList.vue
================================================
<template>
<div class="news-view">
<div class="news-list-nav">
<router-link v-if="page > 1" :to="'/' + type + '/' + (page - 1)">< prev</router-link>
<a v-else class="disabled">< prev</a>
<span>{{ page }}/{{ maxPage }}</span>
<router-link v-if="hasMore" :to="'/' + type + '/' + (page + 1)">more ></router-link>
<a v-else class="disabled">more ></a>
</div>
<transition :name="transition">
<div class="news-list" :key="displayedPage" v-if="displayedPage > 0">
<transition-group tag="ul" name="item">
<item v-for="item in displayedItems" :key="item.id" :item="item">
</item>
</transition-group>
</div>
</transition>
</div>
</template>
<script>
import { watchList } from '../api'
import Item from '../components/Item.vue'
export default {
name: 'item-list',
components: {
Item
},
props: {
type: String
},
data () {
return {
transition: 'slide-right',
displayedPage: Number(this.$route.params.page) || 1,
displayedItems: this.$store.getters.activeItems
}
},
computed: {
page () {
return Number(this.$route.params.page) || 1
},
maxPage () {
const { itemsPerPage, lists } = this.$store.state
return Math.ceil(lists[this.type].length / itemsPerPage)
},
hasMore () {
return this.page < this.maxPage
}
},
beforeMount () {
if (this.$root._isMounted) {
this.loadItems(this.page)
}
// watch the current list for realtime updates
this.unwatchList = watchList(this.type, ids => {
this.$store.commit('SET_LIST', { type: this.type, ids })
this.$store.dispatch('ENSURE_ACTIVE_ITEMS').then(() => {
this.displayedItems = this.$store.getters.activeItems
})
})
},
beforeDestroy () {
this.unwatchList()
},
watch: {
page (to, from) {
this.loadItems(to, from)
}
},
methods: {
loadItems (to = this.page, from = -1) {
this.$bar.start()
this.$store.dispatch('FETCH_LIST_DATA', {
type: this.type
}).then(() => {
if (this.page < 0 || this.page > this.maxPage) {
this.$router.replace(`/${this.type}/1`)
return
}
this.transition = from === -1
? null
: to > from ? 'slide-left' : 'slide-right'
this.displayedPage = to
this.displayedItems = this.$store.getters.activeItems
this.$bar.finish()
})
}
}
}
</script>
<style lang="stylus">
.news-view
padding-top 45px
.news-list-nav, .news-list
background-color #fff
border-radius 2px
.news-list-nav
padding 15px 30px
position fixed
text-align center
top 55px
left 0
right 0
z-index 998
box-shadow 0 1px 2px rgba(0,0,0,.1)
a
margin 0 1em
.disabled
color #ccc
.news-list
position absolute
margin 30px 0
width 100%
transition all .5s cubic-bezier(.55,0,.1,1)
ul
list-style-type none
padding 0
margin 0
.slide-left-enter, .slide-right-leave-to
opacity 0
transform translate(30px, 0)
.slide-left-leave-to, .slide-right-enter
opacity 0
transform translate(-30px, 0)
.item-move, .item-enter-active, .item-leave-active
transition all .5s cubic-bezier(.55,0,.1,1)
.item-enter
opacity 0
transform translate(30px, 0)
.item-leave-active
position absolute
opacity 0
transform translate(30px, 0)
@media (max-width 600px)
.news-list
margin 10px 0
</style>
================================================
FILE: src/views/ItemView.vue
================================================
<template>
<div class="item-view" v-if="item">
<template v-if="item">
<div class="item-view-header">
<a :href="item.url" target="_blank">
<h1>{{ item.title }}</h1>
</a>
<span v-if="item.url" class="host">
({{ item.url | host }})
</span>
<p class="meta">
{{ item.score }} points
| by <router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
{{ item.time | timeAgo }} ago
</p>
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{{ item.kids ? item.descendants + ' comments' : 'No comments yet.' }}
<spinner :show="loading"></spinner>
</p>
<ul v-if="!loading" class="comment-children">
<comment v-for="id in item.kids" :key="id" :id="id"></comment>
</ul>
</div>
</template>
</div>
</template>
<script>
import Spinner from '../components/Spinner.vue'
import Comment from '../components/Comment.vue'
export default {
name: 'item-view',
components: { Spinner, Comment },
data: () => ({
loading: true
}),
computed: {
item () {
return this.$store.state.items[this.$route.params.id]
}
},
// We only fetch the item itself before entering the view, because
// it might take a long time to load threads with hundreds of comments
// due to how the HN Firebase API works.
asyncData ({ store, route: { params: { id }}}) {
return store.dispatch('FETCH_ITEMS', { ids: [id] })
},
title () {
return this.item.title
},
// Fetch comments when mounted on the client
beforeMount () {
this.fetchComments()
},
// refetch comments if item changed
watch: {
item: 'fetchComments'
},
methods: {
fetchComments () {
if (!this.item || !this.item.kids) {
return
}
this.loading = true
fetchComments(this.$store, this.item).then(() => {
this.loading = false
})
}
}
}
// recursively fetch all descendent comments
function fetchComments (store, item) {
if (item && item.kids) {
return store.dispatch('FETCH_ITEMS', {
ids: item.kids
}).then(() => Promise.all(item.kids.map(id => {
return fetchComments(store, store.state.items[id])
})))
}
}
</script>
<style lang="stylus">
.item-view-header
background-color #fff
padding 1.8em 2em 1em
box-shadow 0 1px 2px rgba(0,0,0,.1)
h1
display inline
font-size 1.5em
margin 0
margin-right .5em
.host, .meta, .meta a
color #828282
.meta a
text-decoration underline
.item-view-comments
background-color #fff
margin-top 10px
padding 0 2em .5em
.item-view-comments-header
margin 0
font-size 1.1em
padding 1em 0
position relative
.spinner
display inline-block
margin -15px 0
.comment-children
list-style-type none
padding 0
margin 0
@media (max-width 600px)
.item-view-header
h1
font-size 1.25em
</style>
================================================
FILE: src/views/UserView.vue
================================================
<template>
<div class="user-view">
<template v-if="user">
<h1>User : {{ user.id }}</h1>
<ul class="meta">
<li><span class="label">Created:</span> {{ user.created | timeAgo }} ago</li>
<li><span class="label">Karma:</span> {{ user.karma }}</li>
<li v-if="user.about" v-html="user.about" class="about"></li>
</ul>
<p class="links">
<a :href="'https://news.ycombinator.com/submitted?id=' + user.id">submissions</a> |
<a :href="'https://news.ycombinator.com/threads?id=' + user.id">comments</a>
</p>
</template>
<template v-else-if="user === false">
<h1>User not found.</h1>
</template>
</div>
</template>
<script>
export default {
name: 'user-view',
computed: {
user () {
return this.$store.state.users[this.$route.params.id]
}
},
asyncData ({ store, route: { params: { id }}}) {
return store.dispatch('FETCH_USER', { id })
},
title () {
return this.user
? this.user.id
: 'User not found'
}
}
</script>
<style lang="stylus">
.user-view
background-color #fff
box-sizing border-box
padding 2em 3em
h1
margin 0
font-size 1.5em
.meta
list-style-type none
padding 0
.label
display inline-block
min-width 4em
.about
margin 1em 0
.links a
text-decoration underline
</style>
gitextract_ha5ywnnr/
├── .babelrc
├── .gitignore
├── LICENSE
├── README.md
├── build/
│ ├── setup-dev-server.js
│ ├── webpack.base.config.js
│ ├── webpack.client.config.js
│ └── webpack.server.config.js
├── manifest.json
├── package.json
├── server.js
└── src/
├── App.vue
├── api/
│ ├── create-api-client.js
│ ├── create-api-server.js
│ └── index.js
├── app.js
├── components/
│ ├── Comment.vue
│ ├── Item.vue
│ ├── ProgressBar.vue
│ └── Spinner.vue
├── entry-client.js
├── entry-server.js
├── index.template.html
├── router/
│ └── index.js
├── store/
│ ├── actions.js
│ ├── getters.js
│ ├── index.js
│ └── mutations.js
├── util/
│ ├── filters.js
│ └── title.js
└── views/
├── CreateListView.js
├── ItemList.vue
├── ItemView.vue
└── UserView.vue
SYMBOL INDEX (26 symbols across 13 files)
FILE: build/setup-dev-server.js
constant MFS (line 3) | const MFS = require('memory-fs')
FILE: server.js
constant LRU (line 3) | const LRU = require('lru-cache')
function createRenderer (line 19) | function createRenderer (bundle, options) {
function render (line 81) | function render (req, res) {
FILE: src/api/create-api-client.js
function createAPI (line 4) | function createAPI ({ config, version }) {
FILE: src/api/create-api-server.js
function createAPI (line 4) | function createAPI ({ config, version }) {
FILE: src/api/index.js
function warmCache (line 19) | function warmCache () {
function fetch (line 24) | function fetch (child) {
function fetchIdsByType (line 44) | function fetchIdsByType (type) {
function fetchItem (line 50) | function fetchItem (id) {
function fetchItems (line 54) | function fetchItems (ids) {
function fetchUser (line 58) | function fetchUser (id) {
function watchList (line 62) | function watchList (type, cb) {
FILE: src/app.js
function createApp (line 19) | function createApp () {
FILE: src/entry-client.js
method beforeRouteUpdate (line 12) | beforeRouteUpdate (to, from, next) {
FILE: src/router/index.js
function createRouter (line 11) | function createRouter () {
FILE: src/store/getters.js
method activeIds (line 4) | activeIds (state) {
method activeItems (line 20) | activeItems (state, getters) {
FILE: src/store/index.js
function createStore (line 9) | function createStore () {
FILE: src/util/filters.js
function host (line 1) | function host (url) {
function timeAgo (line 8) | function timeAgo (time) {
function pluralize (line 19) | function pluralize (time, label) {
FILE: src/util/title.js
function getTitle (line 1) | function getTitle (vm) {
method created (line 11) | created () {
method mounted (line 20) | mounted () {
FILE: src/views/CreateListView.js
function createListView (line 8) | function createListView (type) {
Condensed preview — 34 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (54K chars).
[
{
"path": ".babelrc",
"chars": 105,
"preview": "{\n \"presets\": [\n [\"env\", { \"modules\": false }]\n ],\n \"plugins\": [\n \"syntax-dynamic-import\"\n ]\n}\n"
},
{
"path": ".gitignore",
"chars": 70,
"preview": ".DS_Store\nnode_modules/\ndist/\nnpm-debug.log\nyarn-error.log\n.idea\n*.iml"
},
{
"path": "LICENSE",
"chars": 1091,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2013-present, Yuxi (Evan) You\n\nPermission is hereby granted, free of charge, to any"
},
{
"path": "README.md",
"chars": 2735,
"preview": "# vue-hackernews-2.0\n\nHackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side rendering.\n\n<p align=\"ce"
},
{
"path": "build/setup-dev-server.js",
"chars": 2455,
"preview": "const fs = require('fs')\nconst path = require('path')\nconst MFS = require('memory-fs')\nconst webpack = require('webpack'"
},
{
"path": "build/webpack.base.config.js",
"chars": 2021,
"preview": "const path = require('path')\nconst webpack = require('webpack')\nconst ExtractTextPlugin = require('extract-text-webpack-"
},
{
"path": "build/webpack.client.config.js",
"chars": 2062,
"preview": "const webpack = require('webpack')\nconst merge = require('webpack-merge')\nconst base = require('./webpack.base.config')\n"
},
{
"path": "build/webpack.server.config.js",
"chars": 986,
"preview": "const webpack = require('webpack')\nconst merge = require('webpack-merge')\nconst base = require('./webpack.base.config')\n"
},
{
"path": "manifest.json",
"chars": 859,
"preview": "{\n \"name\": \"Vue Hackernews 2.0\",\n \"short_name\": \"Vue HN\",\n \"icons\": [{\n \"src\": \"/public/logo-120.png\",\n \"si"
},
{
"path": "package.json",
"chars": 1836,
"preview": "{\n \"name\": \"vue-hackernews-2.0\",\n \"description\": \"A Vue.js project\",\n \"author\": \"Evan You <yyx990803@gmail.com>\",\n \""
},
{
"path": "server.js",
"chars": 4026,
"preview": "const fs = require('fs')\nconst path = require('path')\nconst LRU = require('lru-cache')\nconst express = require('express'"
},
{
"path": "src/App.vue",
"chars": 2186,
"preview": "<template>\n <div id=\"app\">\n <header class=\"header\">\n <nav class=\"inner\">\n <router-link to=\"/\" exact>\n "
},
{
"path": "src/api/create-api-client.js",
"chars": 191,
"preview": "import Firebase from 'firebase/app'\nimport 'firebase/database'\n\nexport function createAPI ({ config, version }) {\n Fire"
},
{
"path": "src/api/create-api-server.js",
"chars": 828,
"preview": "import Firebase from 'firebase'\nimport LRU from 'lru-cache'\n\nexport function createAPI ({ config, version }) {\n let api"
},
{
"path": "src/api/index.js",
"chars": 1839,
"preview": "// this is aliased in webpack config based on server/client build\nimport { createAPI } from 'create-api'\n\nconst logReque"
},
{
"path": "src/app.js",
"chars": 1277,
"preview": "import Vue from 'vue'\nimport App from './App.vue'\nimport { createStore } from './store'\nimport { createRouter } from './"
},
{
"path": "src/components/Comment.vue",
"chars": 1549,
"preview": "<template>\n <li v-if=\"comment\" class=\"comment\">\n <div class=\"by\">\n <router-link :to=\"'/user/' + comment.by\">{{ "
},
{
"path": "src/components/Item.vue",
"chars": 1754,
"preview": "<template>\n <li class=\"news-item\">\n <span class=\"score\">{{ item.score }}</span>\n <span class=\"title\">\n <temp"
},
{
"path": "src/components/ProgressBar.vue",
"chars": 2005,
"preview": "<!-- borrowed from Nuxt! -->\n\n<template>\n <div class=\"progress\" :style=\"{\n 'width': percent+'%',\n 'height': heigh"
},
{
"path": "src/components/Spinner.vue",
"chars": 1166,
"preview": "<template>\n <transition>\n <svg class=\"spinner\" :class=\"{ show: show }\" v-show=\"show\" width=\"44px\" height=\"44px\" view"
},
{
"path": "src/entry-client.js",
"chars": 1973,
"preview": "import Vue from 'vue'\nimport 'es6-promise/auto'\nimport { createApp } from './app'\nimport ProgressBar from './components/"
},
{
"path": "src/entry-server.js",
"chars": 1984,
"preview": "import { createApp } from './app'\n\nconst isDev = process.env.NODE_ENV !== 'production'\n\n// This exported function will b"
},
{
"path": "src/index.template.html",
"chars": 941,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <title>{{ title }}</title>\n <meta charset=\"utf-8\">\n <meta name=\"mobi"
},
{
"path": "src/router/index.js",
"chars": 971,
"preview": "import Vue from 'vue'\nimport Router from 'vue-router'\n\nVue.use(Router)\n\n// route-level code splitting\nconst createListVi"
},
{
"path": "src/store/actions.js",
"chars": 1329,
"preview": "import {\n fetchUser,\n fetchItems,\n fetchIdsByType\n} from '../api'\n\nexport default {\n // ensure data for rendering gi"
},
{
"path": "src/store/getters.js",
"chars": 639,
"preview": "export default {\n // ids of the items that should be currently displayed based on\n // current list type and current pa"
},
{
"path": "src/store/index.js",
"chars": 538,
"preview": "import Vue from 'vue'\nimport Vuex from 'vuex'\nimport actions from './actions'\nimport mutations from './mutations'\nimport"
},
{
"path": "src/store/mutations.js",
"chars": 460,
"preview": "import Vue from 'vue'\n\nexport default {\n SET_ACTIVE_TYPE: (state, { type }) => {\n state.activeType = type\n },\n\n SE"
},
{
"path": "src/util/filters.js",
"chars": 628,
"preview": "export function host (url) {\n const host = url.replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '')\n const parts = host.sp"
},
{
"path": "src/util/title.js",
"chars": 557,
"preview": "function getTitle (vm) {\n const { title } = vm.$options\n if (title) {\n return typeof title === 'function'\n ? t"
},
{
"path": "src/views/CreateListView.js",
"chars": 624,
"preview": "import ItemList from './ItemList.vue'\n\nconst camelize = str => str.charAt(0).toUpperCase() + str.slice(1)\n\n// This is a "
},
{
"path": "src/views/ItemList.vue",
"chars": 3476,
"preview": "<template>\n <div class=\"news-view\">\n <div class=\"news-list-nav\">\n <router-link v-if=\"page > 1\" :to=\"'/' + type "
},
{
"path": "src/views/ItemView.vue",
"chars": 2984,
"preview": "<template>\n <div class=\"item-view\" v-if=\"item\">\n <template v-if=\"item\">\n <div class=\"item-view-header\">\n "
},
{
"path": "src/views/UserView.vue",
"chars": 1362,
"preview": "<template>\n <div class=\"user-view\">\n <template v-if=\"user\">\n <h1>User : {{ user.id }}</h1>\n <ul class=\"met"
}
]
About this extraction
This page contains the full source code of the vuejs/vue-hackernews-2.0 GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 34 files (48.3 KB), approximately 14.1k tokens, and a symbol index with 26 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.