mirror of
https://github.com/terribleplan/next.js.git
synced 2024-01-19 02:48:18 +00:00
support next/head
This commit is contained in:
parent
9150521c55
commit
89f96cc160
|
@ -3,13 +3,15 @@ import ReactDOM from 'react-dom'
|
||||||
import App from '../lib/app'
|
import App from '../lib/app'
|
||||||
import Link from '../lib/link'
|
import Link from '../lib/link'
|
||||||
import Css from '../lib/css'
|
import Css from '../lib/css'
|
||||||
|
import Head from '../lib/head'
|
||||||
|
|
||||||
const modules = new Map([
|
const modules = new Map([
|
||||||
['react', React],
|
['react', React],
|
||||||
['react-dom', ReactDOM],
|
['react-dom', ReactDOM],
|
||||||
['next/app', App],
|
['next/app', App],
|
||||||
['next/link', Link],
|
['next/link', Link],
|
||||||
['next/css', Css]
|
['next/css', Css],
|
||||||
|
['next/head', Head]
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
69
client/head-manager.js
Normal file
69
client/head-manager.js
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import HTMLDOMPropertyConfig from 'react/lib/HTMLDOMPropertyConfig'
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = ''
|
||||||
|
|
||||||
|
export default class HeadManager {
|
||||||
|
updateHead (head) {
|
||||||
|
const tags = {}
|
||||||
|
head.forEach((h) => {
|
||||||
|
const components = tags[h.type] || []
|
||||||
|
components.push(h)
|
||||||
|
tags[h.type] = components
|
||||||
|
})
|
||||||
|
|
||||||
|
this.updateTitle(tags.title ? tags.title[0] : null)
|
||||||
|
|
||||||
|
const types = ['meta', 'base', 'link', 'style', 'script']
|
||||||
|
types.forEach((type) => {
|
||||||
|
this.updateElements(type, tags[type] || [])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTitle (component) {
|
||||||
|
let title
|
||||||
|
if (component) {
|
||||||
|
const { children } = component.props
|
||||||
|
title = 'string' === typeof children ? children : children.join('')
|
||||||
|
} else {
|
||||||
|
title = DEFAULT_TITLE
|
||||||
|
}
|
||||||
|
if (title !== document.title) document.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
updateElements (type, components) {
|
||||||
|
const headEl = document.getElementsByTagName('head')[0]
|
||||||
|
const oldTags = Array.prototype.slice.call(headEl.querySelectorAll(type + '.next-head'))
|
||||||
|
const newTags = components.map(reactElementToDOM).filter((newTag) => {
|
||||||
|
for (let i = 0, len = oldTags.length; i < len; i++) {
|
||||||
|
const oldTag = oldTags[i]
|
||||||
|
if (oldTag.isEqualNode(newTag)) {
|
||||||
|
oldTags.splice(i, 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
oldTags.forEach((t) => t.parentNode.removeChild(t))
|
||||||
|
newTags.forEach((t) => headEl.appendChild(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactElementToDOM ({ type, props }) {
|
||||||
|
const el = document.createElement(type)
|
||||||
|
for (const p in props) {
|
||||||
|
if (!props.hasOwnProperty(p)) continue
|
||||||
|
if ('children' === p || 'dangerouslySetInnerHTML' === p) continue
|
||||||
|
|
||||||
|
const attr = HTMLDOMPropertyConfig.DOMAttributeNames[p] || p.toLowerCase()
|
||||||
|
el.setAttribute(attr, props[p])
|
||||||
|
}
|
||||||
|
|
||||||
|
const { children, dangerouslySetInnerHTML } = props
|
||||||
|
if (dangerouslySetInnerHTML) {
|
||||||
|
el.innerHTML = dangerouslySetInnerHTML.__html || ''
|
||||||
|
} else if (children) {
|
||||||
|
el.textContent = 'string' === typeof children ? children : children.join('')
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { createElement } from 'react'
|
||||||
import { render } from 'react-dom'
|
import { render } from 'react-dom'
|
||||||
import evalScript from './eval-script'
|
import evalScript from './eval-script'
|
||||||
import Router from './router'
|
import Router from './router'
|
||||||
|
import HeadManager from './head-manager'
|
||||||
import DefaultApp from '../lib/app'
|
import DefaultApp from '../lib/app'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -12,7 +13,8 @@ const App = app ? evalScript(app).default : DefaultApp
|
||||||
const Component = evalScript(component).default
|
const Component = evalScript(component).default
|
||||||
|
|
||||||
const router = new Router({ Component, props })
|
const router = new Router({ Component, props })
|
||||||
|
const headManager = new HeadManager()
|
||||||
const container = document.getElementById('__next')
|
const container = document.getElementById('__next')
|
||||||
const appProps = { Component, props, router }
|
const appProps = { Component, props, router, headManager }
|
||||||
|
|
||||||
render(createElement(App, { ...appProps }), container)
|
render(createElement(App, { ...appProps }), container)
|
||||||
|
|
82
lib/head.js
Normal file
82
lib/head.js
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import React from 'react'
|
||||||
|
import sideEffect from './side-effect'
|
||||||
|
|
||||||
|
class Head extends React.Component {
|
||||||
|
static contextTypes = {
|
||||||
|
headManager: React.PropTypes.object
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
this.context.headManager.updateHead([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceComponents (components) {
|
||||||
|
return components
|
||||||
|
.map((c) => c.props.children)
|
||||||
|
.filter((c) => !!c)
|
||||||
|
.map((children) => React.Children.toArray(children))
|
||||||
|
.reduce((a, b) => a.concat(b), [])
|
||||||
|
.reverse()
|
||||||
|
.filter(unique())
|
||||||
|
.reverse()
|
||||||
|
.map((c) => {
|
||||||
|
const className = (c.className ? c.className + ' ' : '') + 'next-head'
|
||||||
|
return React.cloneElement(c, { className })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOnServer (head) {
|
||||||
|
return head
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStateChange (head) {
|
||||||
|
if (this.context && this.context.headManager) {
|
||||||
|
this.context.headManager.updateHead(head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const METATYPES = ['name', 'httpEquiv', 'charSet', 'itemProp']
|
||||||
|
|
||||||
|
// returns a function for filtering head child elements
|
||||||
|
// which shouldn't be duplicated, like <title/>.
|
||||||
|
|
||||||
|
function unique () {
|
||||||
|
const tags = new Set()
|
||||||
|
const metaTypes = new Set()
|
||||||
|
const metaCategories = {}
|
||||||
|
|
||||||
|
return (h) => {
|
||||||
|
switch (h.type) {
|
||||||
|
case 'title':
|
||||||
|
case 'base':
|
||||||
|
if (tags.has(h.type)) return false
|
||||||
|
tags.add(h.type)
|
||||||
|
break
|
||||||
|
case 'meta':
|
||||||
|
for (let i = 0, len = METATYPES.length; i < len; i++) {
|
||||||
|
const metatype = METATYPES[i]
|
||||||
|
if (!h.props.hasOwnProperty(metatype)) continue
|
||||||
|
|
||||||
|
if ('charSet' === metatype) {
|
||||||
|
if (metaTypes.has(metatype)) return false
|
||||||
|
metaTypes.add(metatype)
|
||||||
|
} else {
|
||||||
|
const category = h.props[metatype]
|
||||||
|
const categories = metaCategories[metatype] || new Set()
|
||||||
|
if (categories.has(category)) return false
|
||||||
|
categories.add(category)
|
||||||
|
metaCategories[metatype] = categories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default sideEffect(reduceComponents, onStateChange, mapOnServer)(Head)
|
101
lib/side-effect.js
Normal file
101
lib/side-effect.js
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import React, { Component } from 'react'
|
||||||
|
|
||||||
|
export default function withSideEffect (reduceComponentsToState, handleStateChangeOnClient, mapStateOnServer) {
|
||||||
|
if (typeof reduceComponentsToState !== 'function') {
|
||||||
|
throw new Error('Expected reduceComponentsToState to be a function.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof handleStateChangeOnClient !== 'function') {
|
||||||
|
throw new Error('Expected handleStateChangeOnClient to be a function.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof mapStateOnServer !== 'undefined' && typeof mapStateOnServer !== 'function') {
|
||||||
|
throw new Error('Expected mapStateOnServer to either be undefined or a function.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName (WrappedComponent) {
|
||||||
|
return WrappedComponent.displayName || WrappedComponent.name || 'Component'
|
||||||
|
}
|
||||||
|
|
||||||
|
return function wrap (WrappedComponent) {
|
||||||
|
if (typeof WrappedComponent !== 'function') {
|
||||||
|
throw new Error('Expected WrappedComponent to be a React component.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountedInstances = new Set()
|
||||||
|
let state
|
||||||
|
let shouldEmitChange = false
|
||||||
|
|
||||||
|
function emitChange (component) {
|
||||||
|
state = reduceComponentsToState([...mountedInstances])
|
||||||
|
|
||||||
|
if (SideEffect.canUseDOM) {
|
||||||
|
handleStateChangeOnClient.call(component, state)
|
||||||
|
} else if (mapStateOnServer) {
|
||||||
|
state = mapStateOnServer(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeEmitChange (component) {
|
||||||
|
if (!shouldEmitChange) return
|
||||||
|
shouldEmitChange = false
|
||||||
|
emitChange(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
class SideEffect extends Component {
|
||||||
|
// Try to use displayName of wrapped component
|
||||||
|
static displayName = `SideEffect(${getDisplayName(WrappedComponent)})`
|
||||||
|
|
||||||
|
static contextTypes = WrappedComponent.contextTypes
|
||||||
|
|
||||||
|
// Expose canUseDOM so tests can monkeypatch it
|
||||||
|
static canUseDOM = 'undefined' !== typeof window
|
||||||
|
|
||||||
|
static peek () {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
static rewind () {
|
||||||
|
if (SideEffect.canUseDOM) {
|
||||||
|
throw new Error('You may only call rewind() on the server. Call peek() to read the current state.')
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeEmitChange()
|
||||||
|
|
||||||
|
const recordedState = state
|
||||||
|
state = undefined
|
||||||
|
mountedInstances.clear()
|
||||||
|
return recordedState
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillMount () {
|
||||||
|
mountedInstances.add(this)
|
||||||
|
shouldEmitChange = true
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
maybeEmitChange(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUpdate () {
|
||||||
|
shouldEmitChange = true
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate () {
|
||||||
|
maybeEmitChange(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
mountedInstances.delete(this)
|
||||||
|
shouldEmitChange = false
|
||||||
|
emitChange(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return <WrappedComponent>{ this.props.children }</WrappedComponent>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SideEffect
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,7 +15,8 @@ export default function bundle (src, dst) {
|
||||||
{
|
{
|
||||||
[require.resolve('react')]: 'react',
|
[require.resolve('react')]: 'react',
|
||||||
[require.resolve('../../lib/link')]: 'next/link',
|
[require.resolve('../../lib/link')]: 'next/link',
|
||||||
[require.resolve('../../lib/css')]: 'next/css'
|
[require.resolve('../../lib/css')]: 'next/css',
|
||||||
|
[require.resolve('../../lib/head')]: 'next/head'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
resolveLoader: {
|
resolveLoader: {
|
||||||
|
|
|
@ -26,7 +26,8 @@ const babelOptions = {
|
||||||
{ src: `npm:${babelRuntimePath}`, expose: 'babel-runtime' },
|
{ src: `npm:${babelRuntimePath}`, expose: 'babel-runtime' },
|
||||||
{ src: `npm:${require.resolve('react')}`, expose: 'react' },
|
{ src: `npm:${require.resolve('react')}`, expose: 'react' },
|
||||||
{ src: `npm:${require.resolve('../../lib/link')}`, expose: 'next/link' },
|
{ src: `npm:${require.resolve('../../lib/link')}`, expose: 'next/link' },
|
||||||
{ src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' }
|
{ src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' },
|
||||||
|
{ src: `npm:${require.resolve('../../lib/head')}`, expose: 'next/head' }
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { createElement } from 'react'
|
||||||
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
||||||
import fs from 'mz/fs'
|
import fs from 'mz/fs'
|
||||||
import Document from '../lib/document'
|
import Document from '../lib/document'
|
||||||
|
import Head from '../lib/head'
|
||||||
import App from '../lib/app'
|
import App from '../lib/app'
|
||||||
import { StyleSheetServer } from '../lib/css'
|
import { StyleSheetServer } from '../lib/css'
|
||||||
|
|
||||||
|
@ -28,10 +29,12 @@ export async function render (path, req, res, { dir = process.cwd(), dev = false
|
||||||
return renderToString(app)
|
return renderToString(app)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const head = Head.rewind() || []
|
||||||
|
|
||||||
const doc = createElement(Document, {
|
const doc = createElement(Document, {
|
||||||
head: [],
|
html,
|
||||||
html: html,
|
head,
|
||||||
css: css,
|
css,
|
||||||
data: { component },
|
data: { component },
|
||||||
hotReload: false,
|
hotReload: false,
|
||||||
dev
|
dev
|
||||||
|
|
Loading…
Reference in a new issue